Release Connectors v0.1.22 with service-desk federation
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 16:11:07 +02:00
parent 79e2315e89
commit b277e8d7ac
22 changed files with 7348 additions and 13 deletions
@@ -632,6 +632,187 @@ class ConnectorKnowledgeSyncRun(Base, TimestampMixin):
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ConnectorServiceDeskProfile(Base, TimestampMixin):
__tablename__ = "connector_service_desk_profiles"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"configuration_id",
name="uq_connector_service_desk_profile_configuration",
),
Index(
"ix_connector_service_desk_profiles_tenant_status",
"tenant_id",
"status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
configuration_id: Mapped[str] = mapped_column(
ForeignKey("connector_configurations.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(
String(30), default="active", nullable=False, index=True
)
integration_mode: Mapped[str] = mapped_column(
String(30), default="synchronize", nullable=False, index=True
)
product: Mapped[str] = mapped_column(
String(50), default="unknown", nullable=False, index=True
)
product_version: Mapped[str | None] = mapped_column(String(100))
desired_maturity: Mapped[str] = mapped_column(
String(30), default="synchronize", nullable=False
)
discovered_maturity: Mapped[str] = mapped_column(
String(30), default="discover", nullable=False
)
source_authority_mode: Mapped[str] = mapped_column(
String(40), default="external_authoritative", nullable=False
)
default_visibility: Mapped[str] = mapped_column(
String(30), default="restricted", nullable=False
)
default_acl_tokens: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
routes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
queue_mappings: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
dynamic_field_mappings: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
capabilities: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
discovery_revision: Mapped[str | None] = mapped_column(String(255), index=True)
discovered_configuration_revision: Mapped[int | None] = mapped_column(Integer)
discovered_configuration_hash: Mapped[str | None] = mapped_column(String(64))
discovery_evidence: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
health_status: Mapped[str] = mapped_column(
String(30), default="unknown", nullable=False, index=True
)
health_details: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
discovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_sync_cursor: Mapped[str | None] = mapped_column(String(4000))
last_high_watermark: Mapped[str | None] = mapped_column(String(500))
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
updated_by: Mapped[str | None] = mapped_column(String(255), index=True)
class ConnectorServiceDeskObject(Base, TimestampMixin):
__tablename__ = "connector_service_desk_objects"
__table_args__ = (
UniqueConstraint(
"profile_id",
"object_type",
"external_id",
name="uq_connector_service_desk_object_identity",
),
Index(
"ix_connector_service_desk_objects_tenant_profile_status",
"tenant_id",
"profile_id",
"status",
),
Index(
"ix_connector_service_desk_objects_tenant_updated",
"tenant_id",
"source_updated_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(
ForeignKey("connector_service_desk_profiles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
object_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
external_id: Mapped[str] = mapped_column(String(255), nullable=False)
external_ticket_number: Mapped[str | None] = mapped_column(String(255), index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
canonical_url: Mapped[str | None] = mapped_column(String(1500))
status: Mapped[str] = mapped_column(
String(30), default="active", nullable=False, index=True
)
source_revision: Mapped[str] = mapped_column(String(255), nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
visibility: Mapped[str] = mapped_column(
String(30), default="restricted", nullable=False
)
acl_tokens: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
mapped_data: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
change_cursor: Mapped[str | None] = mapped_column(String(4000), index=True)
source_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True
)
observed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class ConnectorServiceDeskSyncRun(Base, TimestampMixin):
__tablename__ = "connector_service_desk_sync_runs"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"profile_id",
"idempotency_key",
name="uq_connector_service_desk_sync_run_idempotency",
),
Index(
"ix_connector_service_desk_runs_profile_started",
"tenant_id",
"profile_id",
"started_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(
ForeignKey("connector_service_desk_profiles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
mode: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
cursor_before: Mapped[str | None] = mapped_column(String(4000))
cursor_after: Mapped[str | None] = mapped_column(String(4000))
high_watermark: Mapped[str | None] = mapped_column(String(500))
counts: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
effects: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
created_by: Mapped[str | None] = mapped_column(String(255), index=True)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__all__ = [
"ConnectorConfiguration",
"ConnectorDefinition",
@@ -639,6 +820,9 @@ __all__ = [
"ConnectorKnowledgeObject",
"ConnectorKnowledgeProfile",
"ConnectorKnowledgeSyncRun",
"ConnectorServiceDeskObject",
"ConnectorServiceDeskProfile",
"ConnectorServiceDeskSyncRun",
"ConnectorSanctionsAcquisitionRun",
"ConnectorSanctionsSnapshot",
"ConnectorSimulationRun",
@@ -20,6 +20,8 @@ from govoplan_connectors.backend.db.models import (
ConnectorDefinitionRevision,
ConnectorKnowledgeProfile,
ConnectorKnowledgeSyncRun,
ConnectorServiceDeskProfile,
ConnectorServiceDeskSyncRun,
ConnectorSanctionsAcquisitionRun,
ConnectorSimulationRun,
ConnectorTabularSource,
@@ -41,6 +43,8 @@ class _SubjectSelectors:
simulation_id: str | None
knowledge_profile_id: str | None
knowledge_run_id: str | None
service_desk_profile_id: str | None
service_desk_run_id: str | None
@property
def narrowed(self) -> bool:
@@ -53,6 +57,8 @@ class _SubjectSelectors:
self.simulation_id,
self.knowledge_profile_id,
self.knowledge_run_id,
self.service_desk_profile_id,
self.service_desk_run_id,
)
)
@@ -216,6 +222,45 @@ class ConnectorsDsarProvider:
)
)
if not selectors.narrowed or selectors.service_desk_profile_id:
query = db.query(ConnectorServiceDeskProfile).filter(
ConnectorServiceDeskProfile.tenant_id == tenant_id,
ConnectorServiceDeskProfile.updated_by == selectors.account_id,
)
if selectors.service_desk_profile_id:
query = query.filter(
ConnectorServiceDeskProfile.id
== selectors.service_desk_profile_id
)
records.extend(
_service_desk_profile_attribution(row)
for row in _limited(
query,
ConnectorServiceDeskProfile.created_at,
ConnectorServiceDeskProfile.id,
label="service-desk profile attribution",
)
)
if not selectors.narrowed or selectors.service_desk_run_id:
query = db.query(ConnectorServiceDeskSyncRun).filter(
ConnectorServiceDeskSyncRun.tenant_id == tenant_id,
ConnectorServiceDeskSyncRun.created_by == selectors.account_id,
)
if selectors.service_desk_run_id:
query = query.filter(
ConnectorServiceDeskSyncRun.id == selectors.service_desk_run_id
)
records.extend(
_service_desk_run_attribution(row)
for row in _limited(
query,
ConnectorServiceDeskSyncRun.started_at,
ConnectorServiceDeskSyncRun.id,
label="service-desk run attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Connectors DSAR result limit exceeded; narrow selectors.")
return tuple(
@@ -322,6 +367,14 @@ def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references.get("connectors.knowledge_run"),
references.get("connectors.knowledge_run_id"),
),
"service_desk_profile_id": _coalesce(
references.get("connectors.service_desk_profile"),
references.get("connectors.service_desk_profile_id"),
),
"service_desk_run_id": _coalesce(
references.get("connectors.service_desk_run"),
references.get("connectors.service_desk_run_id"),
),
}
if account is _CONFLICT or any(value is _CONFLICT for value in values.values()):
return None
@@ -337,6 +390,8 @@ def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
simulation_id=_optional_string(values["simulation_id"]),
knowledge_profile_id=_optional_string(values["knowledge_profile_id"]),
knowledge_run_id=_optional_string(values["knowledge_run_id"]),
service_desk_profile_id=_optional_string(values["service_desk_profile_id"]),
service_desk_run_id=_optional_string(values["service_desk_run_id"]),
)
@@ -492,6 +547,49 @@ def _knowledge_run_attribution(
)
def _service_desk_profile_attribution(
row: ConnectorServiceDeskProfile,
) -> DsarRecordRef:
return _record(
resource_type="service_desk_profile_actor_attribution",
resource_id=row.id,
title="External service-desk profile actor attribution",
data={
"service_desk_profile_id": row.id,
"configuration_id": row.configuration_id,
"status": row.status,
"integration_mode": row.integration_mode,
"desired_maturity": row.desired_maturity,
"source_authority_mode": row.source_authority_mode,
"resource_revision": row.resource_revision,
"activity": "updated_external_service_desk_profile",
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
)
def _service_desk_run_attribution(
row: ConnectorServiceDeskSyncRun,
) -> DsarRecordRef:
return _record(
resource_type="service_desk_run_actor_attribution",
resource_id=row.id,
title="External service-desk operation actor attribution",
data={
"service_desk_run_id": row.id,
"service_desk_profile_id": row.profile_id,
"mode": row.mode,
"status": row.status,
"started_at": _iso(row.started_at),
"finished_at": _iso(row.finished_at),
"activity": "started_external_service_desk_operation",
},
observed_at=row.finished_at or row.started_at,
)
def _record(
*,
resource_type: str,
@@ -560,6 +658,8 @@ _RESOURCE_TYPES = {
"simulation_actor_attribution",
"knowledge_profile_actor_attribution",
"knowledge_run_actor_attribution",
"service_desk_profile_actor_attribution",
"service_desk_run_actor_attribution",
}
+239 -6
View File
@@ -50,6 +50,9 @@ from govoplan_connectors.backend.db.models import (
ConnectorKnowledgeObject,
ConnectorKnowledgeProfile,
ConnectorKnowledgeSyncRun,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
ConnectorServiceDeskSyncRun,
ConnectorSanctionsAcquisitionRun,
ConnectorSanctionsSnapshot,
ConnectorSimulationRun,
@@ -69,6 +72,19 @@ from govoplan_connectors.backend.knowledge_connector import (
from govoplan_connectors.backend.knowledge_search import (
create_external_knowledge_search_source,
)
from govoplan_connectors.backend.service_desk_connector import (
SERVICE_DESK_ADMIN_SCOPE,
SERVICE_DESK_CAPABILITY,
SERVICE_DESK_INTERFACE_VERSION,
SERVICE_DESK_PROVIDER_ID,
SERVICE_DESK_READ_SCOPE,
SERVICE_DESK_SYNC_SCOPE,
SERVICE_DESK_UPDATE_SCOPE,
ExternalServiceDeskCapability,
)
from govoplan_connectors.backend.service_desk_search import (
create_external_service_desk_search_source,
)
from govoplan_connectors.backend.dsar_provider import (
CONNECTORS_DSAR_CAPABILITY,
ConnectorsDsarProvider,
@@ -97,12 +113,13 @@ from govoplan_connectors.backend.provider_state import (
TABULAR_PROVIDER_ID,
knowledge_provider_states,
sanctions_provider_states,
service_desk_provider_states,
tabular_provider_states,
)
MODULE_ID = "connectors"
MODULE_VERSION = "0.1.21"
MODULE_VERSION = "0.1.22"
TABULAR_SOURCE_INTERFACE_VERSION = "0.1.0"
DATASOURCE_ORIGIN_INTERFACE_VERSION = "0.1.0"
SANCTIONS_SNAPSHOT_INTERFACE_VERSION = "1.0.0"
@@ -149,6 +166,11 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
reference="tests/test_mediawiki_connector.py",
summary="Exercises deterministic MediaWiki/BlueSpice discovery, stable mapping, bounded deltas, ACL-safe Search, migration loss diagnostics, and publication recovery states.",
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_service_desk_connector.py",
summary="Exercises Znuny/OTRS profile policy, stable ticket mapping, bounded synchronization, Search authorization, governed updates, and unknown-outcome evidence.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/CONNECTOR_SOURCE_LIFECYCLE.md",
@@ -158,8 +180,9 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
known_limits=(
"Tabular origins support immutable snapshots, exact managed CSV/XLSX versions, and read-only PostgreSQL tables; arbitrary REST and other database adapters remain future providers.",
"Feed publication renders a governed document but does not yet push it to an external publishing endpoint.",
"The MediaWiki/BlueSpice adapter publishes revision-checked page edits; generic simulations and all other providers do not imply a live write capability.",
"MediaWiki/BlueSpice publication and governed-sync Znuny/OTRS ticket updates are explicit revision-checked write paths; generic simulations and other providers do not imply a live write capability.",
"Migration into native Wiki is preview-only; a target-side write worker and Wiki-owned mutation contract remain future work.",
"The Znuny/OTRS GenericInterface route map is deployment-defined; queues must be partitioned below the 10000-ticket identity bound and attachment bytes remain provider-side.",
),
supported_authority_modes=(
"external_authoritative",
@@ -173,6 +196,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
"immutable connector snapshots",
"connector acquisition health",
"external knowledge synchronization evidence",
"external service-desk transport and synchronization evidence",
),
non_owned_concepts=(
"datasource catalogue identity and lifecycle",
@@ -180,29 +204,35 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
"data transformations",
"screening dispositions",
"native Wiki spaces, pages, and revision semantics",
"ticket, article, customer, case, and helpdesk business semantics",
),
target_tested_providers=(
TABULAR_PROVIDER_ID,
SANCTIONS_PROVIDER_ID,
KNOWLEDGE_PROVIDER_ID,
SERVICE_DESK_PROVIDER_ID,
),
documentation=ModuleArchitectureDocumentation(
migration=("src/govoplan_connectors/backend/migrations/versions",),
upgrade=(
"docs/CONNECTOR_SOURCE_LIFECYCLE.md",
"docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md",
"docs/ZNUNY_OTRS_CONNECTOR.md",
),
recovery=(
"docs/CONNECTOR_SOURCE_LIFECYCLE.md",
"docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md",
"docs/ZNUNY_OTRS_CONNECTOR.md",
),
security=(
"docs/CONNECTOR_SOURCE_LIFECYCLE.md",
"docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md",
"docs/ZNUNY_OTRS_CONNECTOR.md",
),
operations=(
"docs/CONNECTOR_SOURCE_LIFECYCLE.md",
"docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md",
"docs/ZNUNY_OTRS_CONNECTOR.md",
),
),
)
@@ -384,6 +414,77 @@ EXTERNAL_PROVIDERS = (
"connectors.mediawiki-bluespice",
),
),
ExternalProviderDeclaration(
id=SERVICE_DESK_PROVIDER_ID,
module_id=MODULE_ID,
label="Znuny and OTRS-compatible service-desk provider",
maturity="synchronize",
operations=("discover", "link", "search", "read", "publish", "synchronize"),
objects=(
ProviderObjectDeclaration(
object_type="external_service_desk_ticket",
field_groups=(
"stable_identity",
"queue_and_routing",
"state_and_priority",
"users_and_organizations",
"articles",
"attachment_metadata",
"dynamic_fields",
"permissions",
"source_provenance",
),
authority_modes=(
"external_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
),
default_authority_mode="external_authoritative",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Stable ticket, ticket-number, article, and attachment identities plus provider change timestamps, discovery revisions, cursors, and content hashes are retained.",
concurrency="Profiles use optimistic revisions; governed ticket updates require the synchronized provider revision and a durable idempotency fence.",
freshness="Discovery time, synchronization high-watermark, cursor, provider change time, observation time, and profile health remain explicit.",
health="Product/version verification, authentication, transport, route policy, mapping loss, ACL fallback, Search deferral, cursor bounds, and unknown update outcomes are explicit without exposing credentials.",
max_read_items=500,
idempotency="Every full, delta, or update operation requires a profile-wide caller key; exact replays return committed evidence and mismatched reuse is rejected.",
retry="Discovery and read-only synchronization can be retried deliberately; an update with an unknown outcome must be reconciled against the provider before retry.",
timeout_seconds=20,
conflicts="Queue inclusion, target refs, ACLs, dynamic fields, authority, and route mappings are explicit; updates reject stale provider revisions.",
outcome_unknown="A timed-out or inconclusive remote update remains outcome-unknown behind durable recovery evidence until an operator verifies the provider ticket revision.",
outcome_unknown_supported=True,
evidence="Mapped tickets retain stable external references, revisions, transport and mapping provenance, current ACLs, loss diagnostics, and operation effects; attachment bytes are never retained.",
audit_event_types=(
"connectors.service_desk.profile.created",
"connectors.service_desk.profile.updated",
"connectors.service_desk.profile.discovered",
"connectors.service_desk.profile.synchronized",
"connectors.service_desk.ticket.updated",
),
correction="A later provider revision updates or restores the connector projection while prior synchronization and mutation evidence stays retained.",
rollback="Local projection and terminal recovery evidence commit atomically; a remote provider update cannot be rolled back by a local transaction.",
reconciliation="Rediscover the deployment-defined GenericInterface routes, compare stable ticket revisions, finish or restart bounded synchronization, and inspect unresolved remote mutations.",
outage="Authorized existing projections remain visible with explicit stale health; no provider freshness or write-success claim is made during an outage.",
classifications=("internal", "confidential", "restricted"),
purposes=(
"external service-desk discovery",
"authorized federated search",
"ticket reference or import",
"bounded synchronization",
"governed ticket update",
),
retention="The tenant's connector, Tickets, Helpdesk, Cases, and Records policies determine projection and operation-evidence retention.",
secret_handling="Credentials resolve from a scoped Core envelope and use approved headers or POST bodies; they never enter GET URLs, snapshots, diagnostics, or API responses.",
),
capability_names=(SERVICE_DESK_CAPABILITY,),
interface_names=(SERVICE_DESK_CAPABILITY,),
documentation_topic_ids=(
"connectors.authority-and-effects",
"connectors.znuny-otrs",
),
),
)
@@ -462,6 +563,26 @@ PERMISSIONS = (
"Preview knowledge migration",
"Dry-run a bounded migration into Wiki and inspect loss or conflict diagnostics.",
),
_permission(
SERVICE_DESK_READ_SCOPE,
"View external service-desk tickets",
"View authorized Znuny/OTRS profiles, mapped tickets, and synchronization evidence.",
),
_permission(
SERVICE_DESK_ADMIN_SCOPE,
"Administer service-desk connectors",
"Configure GenericInterface routes, queues, fields, authority, visibility, and discovery.",
),
_permission(
SERVICE_DESK_SYNC_SCOPE,
"Synchronize service-desk tickets",
"Run bounded Znuny/OTRS backfills and change synchronization.",
),
_permission(
SERVICE_DESK_UPDATE_SCOPE,
"Update external service-desk tickets",
"Apply governed revision-checked ticket updates with durable recovery evidence.",
),
)
ROLE_TEMPLATES = (
@@ -482,6 +603,10 @@ ROLE_TEMPLATES = (
KNOWLEDGE_SYNC_SCOPE,
KNOWLEDGE_PUBLISH_SCOPE,
KNOWLEDGE_MIGRATE_SCOPE,
SERVICE_DESK_READ_SCOPE,
SERVICE_DESK_ADMIN_SCOPE,
SERVICE_DESK_SYNC_SCOPE,
SERVICE_DESK_UPDATE_SCOPE,
),
),
RoleTemplate(
@@ -497,13 +622,20 @@ ROLE_TEMPLATES = (
KNOWLEDGE_READ_SCOPE,
KNOWLEDGE_SYNC_SCOPE,
KNOWLEDGE_MIGRATE_SCOPE,
SERVICE_DESK_READ_SCOPE,
SERVICE_DESK_SYNC_SCOPE,
),
),
RoleTemplate(
slug="connector_source_reader",
name="Connector source reader",
description="Discover and preview tabular connector sources.",
permissions=(READ_SCOPE, SANCTIONS_READ_SCOPE, KNOWLEDGE_READ_SCOPE),
permissions=(
READ_SCOPE,
SANCTIONS_READ_SCOPE,
KNOWLEDGE_READ_SCOPE,
SERVICE_DESK_READ_SCOPE,
),
),
)
@@ -542,6 +674,10 @@ def _knowledge_provider(_context) -> ExternalKnowledgeCapability:
return ExternalKnowledgeCapability()
def _service_desk_provider(_context) -> ExternalServiceDeskCapability:
return ExternalServiceDeskCapability()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"connector_definitions": (
@@ -595,6 +731,24 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
.filter(ConnectorKnowledgeSyncRun.tenant_id == tenant_id)
.count()
),
"connector_service_desk_profiles": (
session.query(ConnectorServiceDeskProfile)
.filter(ConnectorServiceDeskProfile.tenant_id == tenant_id)
.count()
),
"connector_service_desk_objects": (
session.query(ConnectorServiceDeskObject)
.filter(
ConnectorServiceDeskObject.tenant_id == tenant_id,
ConnectorServiceDeskObject.status != "deleted",
)
.count()
),
"connector_service_desk_runs": (
session.query(ConnectorServiceDeskSyncRun)
.filter(ConnectorServiceDeskSyncRun.tenant_id == tenant_id)
.count()
),
}
@@ -612,6 +766,9 @@ manifest = ModuleManifest(
"reporting",
"risk_compliance",
"search",
"tickets",
"helpdesk",
"cases",
"wiki",
),
required_capabilities=(
@@ -647,6 +804,10 @@ manifest = ModuleManifest(
name=KNOWLEDGE_CAPABILITY,
version=KNOWLEDGE_INTERFACE_VERSION,
),
ModuleInterfaceProvider(
name=SERVICE_DESK_CAPABILITY,
version=SERVICE_DESK_INTERFACE_VERSION,
),
ModuleInterfaceProvider(name=CONNECTORS_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
@@ -687,6 +848,14 @@ manifest = ModuleManifest(
parent_id="connectors.admin.governed-configurations",
order=30,
),
ViewSurface(
id="connectors.admin.external-service-desk",
module_id=MODULE_ID,
kind="section",
label="External service desk",
parent_id="connectors.admin.governed-configurations",
order=40,
),
),
),
capability_factories={
@@ -696,6 +865,7 @@ manifest = ModuleManifest(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS: (_sanctions_snapshot_provider),
CAPABILITY_CONNECTORS_FEEDS: _feed_provider,
KNOWLEDGE_CAPABILITY: _knowledge_provider,
SERVICE_DESK_CAPABILITY: _service_desk_provider,
CONNECTORS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
@@ -715,6 +885,11 @@ manifest = ModuleManifest(
factory=create_external_knowledge_search_source,
order=65,
),
SearchSourceProviderRegistration(
id=SERVICE_DESK_PROVIDER_ID,
factory=create_external_service_desk_search_source,
order=66,
),
),
architecture=ARCHITECTURE,
external_providers=EXTERNAL_PROVIDERS,
@@ -734,6 +909,11 @@ manifest = ModuleManifest(
provider_id=KNOWLEDGE_PROVIDER_ID,
provider=knowledge_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id=MODULE_ID,
provider_id=SERVICE_DESK_PROVIDER_ID,
provider=service_desk_provider_states,
),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -741,6 +921,9 @@ manifest = ModuleManifest(
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
ConnectorServiceDeskSyncRun,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
ConnectorKnowledgeSyncRun,
ConnectorKnowledgeObject,
ConnectorKnowledgeProfile,
@@ -760,6 +943,9 @@ manifest = ModuleManifest(
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
ConnectorServiceDeskSyncRun,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
ConnectorKnowledgeSyncRun,
ConnectorKnowledgeObject,
ConnectorKnowledgeProfile,
@@ -784,9 +970,9 @@ manifest = ModuleManifest(
body=(
"Connectors correlates only an exact tenant account identifier and can "
"narrow an already verified search to one source, acquisition, "
"definition, configuration, simulation, external-knowledge profile, or "
"knowledge operation. The export identifies the subject's configuration, "
"acquisition, simulation, knowledge-operation, and review activity "
"definition, configuration, simulation, external-knowledge or service-desk "
"profile, or connector operation. The export identifies the subject's "
"configuration, acquisition, simulation, external-operation, and review activity "
"using bounded lifecycle metadata. It never includes credential or "
"endpoint references, source rows, external responses, request payloads, "
"mapping and configuration documents, diagnostics, provenance, hashes, "
@@ -985,6 +1171,53 @@ manifest = ModuleManifest(
related_modules=("risk_compliance", "dataflow"),
order=41,
),
DocumentationTopic(
id="connectors.znuny-otrs",
title="Connect Znuny and OTRS-compatible service desks",
summary="Link, import, synchronize, search, and govern updates to external tickets without collapsing Tickets, Helpdesk, or Cases semantics.",
body=(
"A connector administrator first creates an active governed configuration for a Znuny or OTRS-compatible GenericInterface REST endpoint and keeps credentials in a scoped Core credential envelope. Because GenericInterface route paths and methods are defined by each provider deployment, the service-desk profile explicitly maps search, ticket-read, optional update, and browser-link routes. The profile chooses link, snapshot import, or ongoing synchronization; separately it records external, mirror, linked-reference, or governed-sync authority. Queue mappings decide inclusion, optional target queue references, and current tenant or restricted ACLs. Dynamic-field mappings declare included fields, governed names, and value types. Discovery verifies endpoint health, the exact configuration revision, product/version evidence, and safe technical maturity. A changed endpoint, governed configuration, or route map invalidates discovery and prior projections: synchronization and updates require rediscovery, while Search stays closed until a new full reconciliation. Integration or mapping changes also reset the cursor for a full reconciliation. A bounded full run reconciles stable ticket identities, then synchronize mode changes to cursor-based, revision-aware deltas with overlap-safe provider timestamps; delta cannot bootstrap an unreconciled profile, and supplied cursors must match committed state. The connector maps queues, state, priority, type, owners, responsible users, customers, organizations, services, SLAs, articles, attachment metadata, dynamic fields, provenance, and structured loss diagnostics. It never retains attachment bytes. GenericInterface has no portable standard ticket ACL, so provider-supplied GovOPlaN ACL metadata wins when present; otherwise reviewed queue or restricted profile defaults apply. Search includes only active authorized projections and rechecks tenant, profile status, read scope, current configuration discovery, and current ACL for every result. A ticket remains an external ticket reference: creating or relating a GovOPlaN Ticket, Helpdesk item, or Case belongs to those modules. Remote updates are available only in governed-sync mode after discovery confirms an update route, require the synchronized external revision and a unique idempotency key, and retain durable outcome evidence. If the result is unknown, operators must inspect the provider revision before retry. Providers with more than 10000 identities must be partitioned into queue-scoped profiles; too many tickets at one timestamp also require narrower partitions. During outages, existing authorized projections remain visibly stale and never imply current provider state."
),
layer="configured",
documentation_types=("admin", "user"),
audience=(
"operator",
"module_admin",
"integration_admin",
"service_desk_manager",
"auditor",
),
related_modules=(
"core",
"search",
"tickets",
"helpdesk",
"cases",
"audit",
"policy",
),
order=44,
translations={
"de": {
"title": "Znuny- und OTRS-kompatible Service-Desks anbinden",
"summary": "Externe Tickets verknüpfen, importieren, synchronisieren, durchsuchen und gesteuert aktualisieren, ohne die Fachsemantik von Tickets, Helpdesk oder Cases zu vermischen.",
"body": (
"Die Connector-Administration erstellt zuerst eine aktive, gesteuerte Konfiguration für einen Znuny- oder OTRS-kompatiblen GenericInterface-REST-Endpunkt; Zugangsdaten bleiben in einem zweckgebundenen Core-Umschlag. Da Pfade und Methoden im GenericInterface je Installation festgelegt werden, ordnet das Service-Desk-Profil Suche, Ticketabruf, optionale Aktualisierung und Browserlink ausdrücklich zu. Das Profil wählt Verknüpfung, Snapshot-Import oder fortlaufende Synchronisierung und legt getrennt davon Quellhoheit, Spiegelung, Referenz oder gesteuerte beidseitige Aktualisierung fest. Warteschlangen-Zuordnungen bestimmen Einschluss, optionale Zielreferenz und aktuelle mandantenweite oder eingeschränkte ACLs. Dynamische Felder erhalten freigegebene Namen und Datentypen. Die Erkennung prüft Gesundheit, die genaue Konfigurationsrevision, Produkt-/Versionsnachweis und technische Reife. Ein geänderter Endpunkt, eine geänderte gesteuerte Konfiguration oder Routenabbildung macht Nachweis und bisherige Projektionen ungültig; Synchronisierung und Aktualisierungen erfordern erneute Erkennung, Search zusätzlich einen neuen Vollabgleich. Änderungen an Integration oder Abbildungen setzen den Cursor ebenfalls zurück. Erst nach einem begrenzten Vollabgleich verwendet der Synchronisierungsmodus revisionsbewusste, überlappungssichere Delta-Cursor; Delta kann kein unabgeglichenes Profil initialisieren und übergebene Cursor müssen dem gespeicherten Stand entsprechen. Abgebildet werden stabile Ticket-, Artikel- und Anhangskennungen, Warteschlange, Status, Priorität, Typ, Bearbeitende, Kundschaft, Organisationen, Services, SLAs, Artikel, Anhangsmetadaten, dynamische Felder, Herkunft und Verlustdiagnosen. Anhangsdaten werden nie gespeichert. Da das Standard-GenericInterface keine portable Ticket-ACL liefert, haben ausdrücklich gelieferte GovOPlaN-ACL-Metadaten Vorrang; sonst greifen geprüfte Warteschlangen- oder eingeschränkte Profilvorgaben. Search prüft bei jedem Treffer Mandant, Profilstatus, Leserecht, aktuellen Konfigurationsnachweis und aktuelle ACL neu. Ein externes Ticket bleibt eine externe Referenz; fachliche Tickets, Helpdesk-Vorgänge und Cases werden ausschließlich von den jeweiligen Modulen erzeugt oder verknüpft. Externe Änderungen sind nur im Modus der gesteuerten Synchronisierung mit erkannter Update-Route, erwarteter Quellrevision und eindeutigem Idempotenzschlüssel zulässig. Ein unbekanntes Ergebnis muss vor einem erneuten Versuch am Anbieter geprüft werden. Profile mit mehr als 10000 Ticketkennungen oder zu vielen Änderungen am selben Zeitstempel müssen nach Warteschlangen enger aufgeteilt werden. Bei einem Ausfall bleiben bestehende Projektionen nur für weiterhin Berechtigte und mit sichtbarer veralteter Gesundheit verfügbar."
),
}
},
metadata={
"kind": "guide",
"help_contexts": ["connectors.admin.external-service-desk"],
"prerequisites": [
"An active governed Znuny/OTRS GenericInterface REST configuration exists.",
"Deployment-defined routes, queue partitions, authority, and fallback ACLs have been reviewed.",
"Tickets, Helpdesk, Cases, and Search remain optional capability-separated consumers.",
],
"outcome": "External tickets remain identity-stable, loss-visible, ACL-safe, recoverable, and semantically separate from GovOPlaN domain records.",
"verification": "Rediscover the profile, finish a keyed full run, run a keyed delta, inspect mapping diagnostics, verify one allowed and denied Search principal, and reconcile every outcome-unknown update before retry.",
},
),
),
)
@@ -0,0 +1,201 @@
"""Znuny and OTRS-compatible service-desk connector state
Revision ID: c0f1a2b3c4d5
Revises: b9e0f1a2c3d4
Create Date: 2026-08-22 15:15:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c0f1a2b3c4d5"
down_revision = "b9e0f1a2c3d4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"connector_service_desk_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("configuration_id", sa.String(length=36), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("integration_mode", sa.String(length=30), nullable=False),
sa.Column("product", sa.String(length=50), nullable=False),
sa.Column("product_version", sa.String(length=100), nullable=True),
sa.Column("desired_maturity", sa.String(length=30), nullable=False),
sa.Column("discovered_maturity", sa.String(length=30), nullable=False),
sa.Column("source_authority_mode", sa.String(length=40), nullable=False),
sa.Column("default_visibility", sa.String(length=30), nullable=False),
sa.Column("default_acl_tokens", sa.JSON(), nullable=False),
sa.Column("routes", sa.JSON(), nullable=False),
sa.Column("queue_mappings", sa.JSON(), nullable=False),
sa.Column("dynamic_field_mappings", sa.JSON(), nullable=False),
sa.Column("capabilities", sa.JSON(), nullable=False),
sa.Column("discovery_revision", sa.String(length=255), nullable=True),
sa.Column("discovered_configuration_revision", sa.Integer(), nullable=True),
sa.Column("discovered_configuration_hash", sa.String(length=64), nullable=True),
sa.Column("discovery_evidence", sa.JSON(), nullable=False),
sa.Column("health_status", sa.String(length=30), nullable=False),
sa.Column("health_details", sa.JSON(), nullable=False),
sa.Column("discovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_sync_cursor", sa.String(length=4000), nullable=True),
sa.Column("last_high_watermark", sa.String(length=500), nullable=True),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["configuration_id"],
["connector_configurations.id"],
name=op.f(
"fk_connector_service_desk_profiles_configuration_id_connector_configurations"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_connector_service_desk_profiles")),
sa.UniqueConstraint(
"tenant_id",
"configuration_id",
name="uq_connector_service_desk_profile_configuration",
),
)
for name, columns in (
("ix_connector_service_desk_profiles_tenant_id", ["tenant_id"]),
("ix_connector_service_desk_profiles_configuration_id", ["configuration_id"]),
("ix_connector_service_desk_profiles_status", ["status"]),
("ix_connector_service_desk_profiles_integration_mode", ["integration_mode"]),
("ix_connector_service_desk_profiles_product", ["product"]),
("ix_connector_service_desk_profiles_discovery_revision", ["discovery_revision"]),
("ix_connector_service_desk_profiles_health_status", ["health_status"]),
("ix_connector_service_desk_profiles_updated_by", ["updated_by"]),
):
op.create_index(op.f(name), "connector_service_desk_profiles", columns)
op.create_index(
"ix_connector_service_desk_profiles_tenant_status",
"connector_service_desk_profiles",
["tenant_id", "status"],
)
op.create_table(
"connector_service_desk_objects",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("object_type", sa.String(length=40), nullable=False),
sa.Column("external_id", sa.String(length=255), nullable=False),
sa.Column("external_ticket_number", sa.String(length=255), nullable=True),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("canonical_url", sa.String(length=1500), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("source_revision", sa.String(length=255), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("visibility", sa.String(length=30), nullable=False),
sa.Column("acl_tokens", sa.JSON(), nullable=False),
sa.Column("mapped_data", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("change_cursor", sa.String(length=4000), nullable=True),
sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["profile_id"],
["connector_service_desk_profiles.id"],
name=op.f(
"fk_connector_service_desk_objects_profile_id_connector_service_desk_profiles"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_connector_service_desk_objects")),
sa.UniqueConstraint(
"profile_id",
"object_type",
"external_id",
name="uq_connector_service_desk_object_identity",
),
)
for name, columns in (
("ix_connector_service_desk_objects_tenant_id", ["tenant_id"]),
("ix_connector_service_desk_objects_profile_id", ["profile_id"]),
("ix_connector_service_desk_objects_object_type", ["object_type"]),
("ix_connector_service_desk_objects_external_ticket_number", ["external_ticket_number"]),
("ix_connector_service_desk_objects_status", ["status"]),
("ix_connector_service_desk_objects_content_hash", ["content_hash"]),
("ix_connector_service_desk_objects_change_cursor", ["change_cursor"]),
("ix_connector_service_desk_objects_source_updated_at", ["source_updated_at"]),
("ix_connector_service_desk_objects_observed_at", ["observed_at"]),
):
op.create_index(op.f(name), "connector_service_desk_objects", columns)
op.create_index(
"ix_connector_service_desk_objects_tenant_profile_status",
"connector_service_desk_objects",
["tenant_id", "profile_id", "status"],
)
op.create_index(
"ix_connector_service_desk_objects_tenant_updated",
"connector_service_desk_objects",
["tenant_id", "source_updated_at"],
)
op.create_table(
"connector_service_desk_sync_runs",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("mode", sa.String(length=40), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_hash", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("cursor_before", sa.String(length=4000), nullable=True),
sa.Column("cursor_after", sa.String(length=4000), nullable=True),
sa.Column("high_watermark", sa.String(length=500), nullable=True),
sa.Column("counts", sa.JSON(), nullable=False),
sa.Column("effects", sa.JSON(), nullable=False),
sa.Column("diagnostics", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["profile_id"],
["connector_service_desk_profiles.id"],
name=op.f(
"fk_connector_service_desk_sync_runs_profile_id_connector_service_desk_profiles"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_connector_service_desk_sync_runs")),
sa.UniqueConstraint(
"tenant_id",
"profile_id",
"idempotency_key",
name="uq_connector_service_desk_sync_run_idempotency",
),
)
for name, columns in (
("ix_connector_service_desk_sync_runs_tenant_id", ["tenant_id"]),
("ix_connector_service_desk_sync_runs_profile_id", ["profile_id"]),
("ix_connector_service_desk_sync_runs_mode", ["mode"]),
("ix_connector_service_desk_sync_runs_status", ["status"]),
("ix_connector_service_desk_sync_runs_created_by", ["created_by"]),
("ix_connector_service_desk_sync_runs_started_at", ["started_at"]),
):
op.create_index(op.f(name), "connector_service_desk_sync_runs", columns)
op.create_index(
"ix_connector_service_desk_runs_profile_started",
"connector_service_desk_sync_runs",
["tenant_id", "profile_id", "started_at"],
)
def downgrade() -> None:
op.drop_table("connector_service_desk_sync_runs")
op.drop_table("connector_service_desk_objects")
op.drop_table("connector_service_desk_profiles")
@@ -7,9 +7,13 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_connectors.backend.db.models import (
ConnectorConfiguration,
ConnectorKnowledgeObject,
ConnectorKnowledgeProfile,
ConnectorKnowledgeSyncRun,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
ConnectorServiceDeskSyncRun,
ConnectorSanctionsAcquisitionRun,
ConnectorSanctionsSnapshot,
ConnectorTabularSource,
@@ -23,6 +27,7 @@ from govoplan_core.core.provider_governance import (
TABULAR_PROVIDER_ID = "connectors.tabular_snapshot"
SANCTIONS_PROVIDER_ID = "connectors.sanctions_snapshot"
KNOWLEDGE_PROVIDER_ID = "connectors.mediawiki.pages"
SERVICE_DESK_PROVIDER_ID = "connectors.znuny.tickets"
def tabular_provider_states(
@@ -120,6 +125,39 @@ def knowledge_provider_states(
)
def service_desk_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
session = _session(context)
statement = select(ConnectorServiceDeskProfile)
if context.tenant_id is not None:
statement = statement.where(
ConnectorServiceDeskProfile.tenant_id == context.tenant_id
)
profiles = tuple(
session.scalars(
statement.order_by(
ConnectorServiceDeskProfile.tenant_id,
ConnectorServiceDeskProfile.id,
).limit(context.max_items + 1)
)
)[: context.max_items]
counts = _service_desk_counts(session, profiles)
latest_runs = _latest_service_desk_runs(session, profiles)
configurations = _service_desk_configurations(session, profiles)
observed_at = datetime.now(UTC)
return tuple(
_service_desk_state(
profile,
observed_at=observed_at,
object_count=counts.get(profile.id, 0),
latest_run=latest_runs.get(profile.id),
configuration=configurations.get(profile.configuration_id),
)
for profile in profiles
)
def _session(context: ExternalProviderStateContext) -> Session:
if not isinstance(context.session, Session):
raise RuntimeError("Connectors provider state requires a database session.")
@@ -246,6 +284,72 @@ def _latest_knowledge_runs(
return latest
def _service_desk_counts(
session: Session,
profiles: tuple[ConnectorServiceDeskProfile, ...],
) -> dict[str, int]:
profile_ids = tuple(item.id for item in profiles)
if not profile_ids:
return {}
return {
str(profile_id): int(count)
for profile_id, count in session.execute(
select(
ConnectorServiceDeskObject.profile_id,
func.count(ConnectorServiceDeskObject.id),
)
.where(
ConnectorServiceDeskObject.profile_id.in_(profile_ids),
ConnectorServiceDeskObject.status != "deleted",
)
.group_by(ConnectorServiceDeskObject.profile_id)
)
}
def _latest_service_desk_runs(
session: Session,
profiles: tuple[ConnectorServiceDeskProfile, ...],
) -> dict[str, ConnectorServiceDeskSyncRun]:
profile_ids = tuple(item.id for item in profiles)
if not profile_ids:
return {}
rows = tuple(
session.scalars(
select(ConnectorServiceDeskSyncRun)
.where(ConnectorServiceDeskSyncRun.profile_id.in_(profile_ids))
.order_by(
ConnectorServiceDeskSyncRun.profile_id,
ConnectorServiceDeskSyncRun.started_at.desc(),
ConnectorServiceDeskSyncRun.id.desc(),
)
)
)
latest: dict[str, ConnectorServiceDeskSyncRun] = {}
for row in rows:
latest.setdefault(row.profile_id, row)
return latest
def _service_desk_configurations(
session: Session,
profiles: tuple[ConnectorServiceDeskProfile, ...],
) -> dict[str, ConnectorConfiguration]:
configuration_ids = tuple(
dict.fromkeys(profile.configuration_id for profile in profiles)
)
if not configuration_ids:
return {}
return {
row.id: row
for row in session.scalars(
select(ConnectorConfiguration).where(
ConnectorConfiguration.id.in_(configuration_ids)
)
)
}
def _knowledge_state(
profile: ConnectorKnowledgeProfile,
*,
@@ -309,6 +413,84 @@ def _knowledge_state(
)
def _service_desk_state(
profile: ConnectorServiceDeskProfile,
*,
observed_at: datetime,
object_count: int,
latest_run: ConnectorServiceDeskSyncRun | None,
configuration: ConnectorConfiguration | None,
) -> ExternalProviderRuntimeState:
configured = configuration is not None
active = (
profile.status == "active"
and configuration is not None
and configuration.status == "active"
)
discovery_current = bool(
configuration is not None
and profile.discovered_configuration_revision
== configuration.resource_revision
and profile.discovered_configuration_hash == configuration.effective_hash
)
health = (
"inactive"
if not active
else "warning"
if not discovery_current
else "healthy"
if profile.health_status == "healthy"
else "warning"
if profile.health_status in {"unknown", "degraded"}
else "error"
)
last_success = (
latest_run.finished_at
if latest_run is not None and latest_run.status == "completed"
else profile.discovered_at
)
unresolved = latest_run is not None and latest_run.status == "outcome_unknown"
return ExternalProviderRuntimeState(
provider_id=SERVICE_DESK_PROVIDER_ID,
binding_ref=f"connectors:service-desk-profile:{profile.id}",
authority_mode=profile.source_authority_mode,
observed_at=observed_at,
configured=configured,
active=active,
health=health,
freshness="unknown" if active else "not_applicable",
conflict="pending" if unresolved else "not_applicable",
recovery=(
"attention"
if latest_run is not None
and latest_run.status in {"failed", "outcome_unknown"}
else "ready"
if active
else "not_applicable"
),
last_success_at=_aware(last_success),
detail=(
f"{profile.product} service-desk profile is synchronized and ACL-rechecked."
if active and discovery_current and profile.health_status == "healthy"
else "Service-desk configuration changed; rediscovery is required."
if active and not discovery_current
else "Service-desk profile requires discovery, synchronization, or recovery review."
if active
else "Service-desk profile is paused."
),
metrics={
"product": profile.product,
"product_version": profile.product_version,
"integration_mode": profile.integration_mode,
"desired_maturity": profile.desired_maturity,
"discovered_maturity": profile.discovered_maturity,
"discovery_current": discovery_current,
"active_objects": int(object_count),
"last_run_status": latest_run.status if latest_run is not None else None,
},
)
def _sanctions_state(
run: ConnectorSanctionsAcquisitionRun,
*,
@@ -359,8 +541,10 @@ def _aware(value: datetime | None) -> datetime | None:
__all__ = [
"KNOWLEDGE_PROVIDER_ID",
"SANCTIONS_PROVIDER_ID",
"SERVICE_DESK_PROVIDER_ID",
"TABULAR_PROVIDER_ID",
"sanctions_provider_states",
"knowledge_provider_states",
"service_desk_provider_states",
"tabular_provider_states",
]
+226
View File
@@ -112,6 +112,30 @@ from govoplan_connectors.backend.recovery import (
ConnectorRecoveryError,
begin_connector_read_snapshot,
)
from govoplan_connectors.backend.service_desk_connector import (
ServiceDeskConnectorError,
create_profile as create_service_desk_profile,
discover_profile as discover_service_desk_profile,
list_objects as list_service_desk_objects,
list_profiles as list_service_desk_profiles,
list_runs as list_service_desk_runs,
synchronize_profile as synchronize_service_desk_profile,
update_profile as update_service_desk_profile,
update_ticket as update_service_desk_ticket,
)
from govoplan_connectors.backend.service_desk_schemas import (
ServiceDeskDiscoveryResponse,
ServiceDeskObjectListResponse,
ServiceDeskProfileCreateRequest,
ServiceDeskProfileItem,
ServiceDeskProfileListResponse,
ServiceDeskProfileUpdateRequest,
ServiceDeskSyncRequest,
ServiceDeskSyncRunItem,
ServiceDeskSyncRunListResponse,
ServiceDeskTicketUpdateRequest,
ServiceDeskTicketUpdateResponse,
)
from govoplan_connectors.backend.sanctions_sources import (
SANCTIONS_READ_SCOPE,
SANCTIONS_REFRESH_SCOPE,
@@ -243,6 +267,38 @@ def _knowledge_http_error(exc: KnowledgeConnectorError) -> HTTPException:
)
def _service_desk_http_error(exc: ServiceDeskConnectorError) -> HTTPException:
if exc.code.endswith("_not_found"):
status_code = status.HTTP_404_NOT_FOUND
elif exc.code == "forbidden":
status_code = status.HTTP_403_FORBIDDEN
elif exc.code in {
"authority_mode_invalid",
"cursor_conflict",
"external_authoritative",
"external_revision_conflict",
"idempotency_conflict",
"operation_unresolved",
"profile_conflict",
"profile_paused",
"rediscovery_required",
"update_outcome_unknown",
}:
status_code = status.HTTP_409_CONFLICT
elif exc.retryable or exc.code.endswith("_unavailable"):
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
else:
status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
return HTTPException(
status_code=status_code,
detail={
"code": exc.code,
"message": str(exc),
"retryable": exc.retryable,
},
)
@router.post("/feeds/preview", response_model=FeedDocumentResponse)
def api_preview_feed(
payload: FeedAcquireRequest,
@@ -1232,6 +1288,176 @@ def api_publish_knowledge_page(
raise _knowledge_http_error(exc) from exc
@router.get(
"/service-desk/profiles",
response_model=ServiceDeskProfileListResponse,
)
def api_list_service_desk_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskProfileListResponse:
try:
return ServiceDeskProfileListResponse(
items=list(list_service_desk_profiles(session, principal))
)
except ServiceDeskConnectorError as exc:
raise _service_desk_http_error(exc) from exc
@router.post(
"/service-desk/profiles",
response_model=ServiceDeskProfileItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_service_desk_profile(
payload: ServiceDeskProfileCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskProfileItem:
try:
return create_service_desk_profile(session, principal, payload)
except ServiceDeskConnectorError as exc:
session.rollback()
raise _service_desk_http_error(exc) from exc
@router.put(
"/service-desk/profiles/{profile_id}",
response_model=ServiceDeskProfileItem,
)
def api_update_service_desk_profile(
profile_id: str,
payload: ServiceDeskProfileUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskProfileItem:
try:
return update_service_desk_profile(
session,
principal,
profile_id=profile_id,
payload=payload,
)
except ServiceDeskConnectorError as exc:
session.rollback()
raise _service_desk_http_error(exc) from exc
@router.post(
"/service-desk/profiles/{profile_id}/discover",
response_model=ServiceDeskDiscoveryResponse,
)
def api_discover_service_desk_profile(
profile_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskDiscoveryResponse:
try:
return discover_service_desk_profile(
session,
principal,
profile_id=profile_id,
)
except ServiceDeskConnectorError as exc:
raise _service_desk_http_error(exc) from exc
@router.post(
"/service-desk/profiles/{profile_id}/sync",
response_model=ServiceDeskSyncRunItem,
)
def api_synchronize_service_desk_profile(
profile_id: str,
payload: ServiceDeskSyncRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskSyncRunItem:
try:
return synchronize_service_desk_profile(
session,
principal,
profile_id=profile_id,
payload=payload,
)
except ServiceDeskConnectorError as exc:
raise _service_desk_http_error(exc) from exc
@router.get(
"/service-desk/profiles/{profile_id}/objects",
response_model=ServiceDeskObjectListResponse,
)
def api_list_service_desk_objects(
profile_id: str,
cursor: str | None = Query(default=None, max_length=36),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskObjectListResponse:
try:
items, next_cursor = list_service_desk_objects(
session,
principal,
profile_id=profile_id,
cursor=cursor,
limit=limit,
)
return ServiceDeskObjectListResponse(
items=list(items),
next_cursor=next_cursor,
)
except ServiceDeskConnectorError as exc:
raise _service_desk_http_error(exc) from exc
@router.get(
"/service-desk/runs",
response_model=ServiceDeskSyncRunListResponse,
)
def api_list_service_desk_runs(
profile_id: str | None = Query(default=None, max_length=36),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskSyncRunListResponse:
try:
return ServiceDeskSyncRunListResponse(
items=list(
list_service_desk_runs(
session,
principal,
profile_id=profile_id,
limit=limit,
)
)
)
except ServiceDeskConnectorError as exc:
raise _service_desk_http_error(exc) from exc
@router.post(
"/service-desk/profiles/{profile_id}/tickets/{external_ticket_id}/update",
response_model=ServiceDeskTicketUpdateResponse,
)
def api_update_service_desk_ticket(
profile_id: str,
external_ticket_id: str,
payload: ServiceDeskTicketUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceDeskTicketUpdateResponse:
try:
return update_service_desk_ticket(
session,
principal,
profile_id=profile_id,
external_ticket_id=external_ticket_id,
payload=payload,
)
except ServiceDeskConnectorError as exc:
raise _service_desk_http_error(exc) from exc
def _source_response(source: TabularSource) -> TabularSourceResponse:
return TabularSourceResponse(
ref=source.ref,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,371 @@
from __future__ import annotations
from datetime import datetime
import json
from typing import Any, Literal
from urllib.parse import parse_qsl, urlsplit
from pydantic import BaseModel, ConfigDict, Field, model_validator
IntegrationMode = Literal["link", "import", "synchronize"]
ServiceDeskMaturity = Literal[
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
]
_CREDENTIAL_CONTROL_KEYS = {
"authorization",
"auth_mode",
"sessionid",
"userlogin",
"customeruserlogin",
"password",
"x-otrs-header-sessionid",
"x-otrs-header-userlogin",
"x-otrs-header-customeruserlogin",
"x-otrs-header-password",
}
class ServiceDeskRouteMapping(BaseModel):
model_config = ConfigDict(extra="forbid")
search_path: str = Field(default="/Ticket/Search", min_length=1, max_length=500)
ticket_path: str = Field(default="/Ticket/{ticket_id}", min_length=1, max_length=500)
update_path: str | None = Field(default=None, max_length=500)
search_method: Literal["GET", "POST"] = "POST"
ticket_method: Literal["GET", "POST"] = "GET"
update_method: Literal["PATCH", "POST", "PUT"] = "PATCH"
ticket_web_url_template: str | None = Field(default=None, max_length=1500)
search_filters: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_templates(self) -> "ServiceDeskRouteMapping":
for field_name in ("search_path", "ticket_path", "update_path"):
value = getattr(self, field_name)
if value is None:
continue
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc or parsed.username or parsed.password:
raise ValueError(f"{field_name} must be relative to the governed endpoint")
if _credential_query_keys(parsed.query):
raise ValueError(f"{field_name} cannot contain authentication controls")
if any(part == ".." for part in parsed.path.split("/")):
raise ValueError(f"{field_name} cannot traverse parent paths")
if "{ticket_id}" not in self.ticket_path:
raise ValueError("ticket_path must contain {ticket_id}")
if self.update_path is not None and "{ticket_id}" not in self.update_path:
raise ValueError("update_path must contain {ticket_id}")
if (
self.ticket_web_url_template is not None
and "{ticket_id}" not in self.ticket_web_url_template
and "{ticket_number}" not in self.ticket_web_url_template
):
raise ValueError(
"ticket_web_url_template must contain {ticket_id} or {ticket_number}"
)
if self.ticket_web_url_template is not None:
parsed = urlsplit(self.ticket_web_url_template)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("ticket_web_url_template must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise ValueError("ticket_web_url_template cannot contain credentials")
if _credential_query_keys(parsed.query):
raise ValueError(
"ticket_web_url_template cannot contain authentication controls"
)
if len(self.search_filters) > 100:
raise ValueError("search_filters supports at most 100 governed criteria")
reserved = _CREDENTIAL_CONTROL_KEYS | {
"limit",
"sortby",
"orderby",
"ticketchangetimenewerdate",
}
filter_names = {str(value).strip().casefold() for value in self.search_filters}
if "" in filter_names:
raise ValueError("search_filters keys cannot be empty")
if reserved.intersection(filter_names):
raise ValueError("search_filters cannot override cursors, bounds, ordering, or authentication")
if len(json.dumps(self.search_filters, default=str)) > 20_000:
raise ValueError("search_filters exceeds the 20000-character policy limit")
return self
class ServiceDeskQueueMapping(BaseModel):
model_config = ConfigDict(extra="forbid")
source_queue: str = Field(min_length=1, max_length=300)
target_queue_ref: str | None = Field(default=None, max_length=255)
include: bool = True
visibility: Literal["tenant", "restricted"] = "restricted"
acl_tokens: list[str] = Field(default_factory=list, max_length=200)
class ServiceDeskDynamicFieldMapping(BaseModel):
model_config = ConfigDict(extra="forbid")
source_name: str = Field(min_length=1, max_length=255)
target_name: str | None = Field(default=None, max_length=255)
include: bool = True
value_type: Literal["string", "number", "boolean", "date", "json"] = "string"
class ServiceDeskProfileCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
configuration_id: str = Field(min_length=1, max_length=36)
integration_mode: IntegrationMode = "synchronize"
desired_maturity: ServiceDeskMaturity = "synchronize"
source_authority_mode: Literal[
"external_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
] = "external_authoritative"
default_visibility: Literal["tenant", "restricted"] = "restricted"
default_acl_tokens: list[str] = Field(default_factory=list, max_length=200)
routes: ServiceDeskRouteMapping = Field(default_factory=ServiceDeskRouteMapping)
queue_mappings: list[ServiceDeskQueueMapping] = Field(
default_factory=list, max_length=500
)
dynamic_field_mappings: list[ServiceDeskDynamicFieldMapping] = Field(
default_factory=list, max_length=500
)
@model_validator(mode="after")
def validate_policy(self) -> "ServiceDeskProfileCreateRequest":
_validate_profile_policy(
integration_mode=self.integration_mode,
authority_mode=self.source_authority_mode,
visibility=self.default_visibility,
acl_tokens=self.default_acl_tokens,
)
order = ("discover", "link", "search", "read", "publish", "synchronize")
maximum = {"link": "search", "import": "read", "synchronize": "synchronize"}[
self.integration_mode
]
minimum = {"link": "link", "import": "read", "synchronize": "synchronize"}[
self.integration_mode
]
if order.index(self.desired_maturity) < order.index(minimum):
raise ValueError(
f"{self.integration_mode} mode requires at least {minimum} maturity"
)
if order.index(self.desired_maturity) > order.index(maximum):
raise ValueError(
f"{self.integration_mode} mode cannot declare maturity above {maximum}"
)
return self
class ServiceDeskProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_resource_revision: int = Field(ge=1)
status: Literal["active", "paused"] | None = None
integration_mode: IntegrationMode | None = None
desired_maturity: ServiceDeskMaturity | None = None
source_authority_mode: Literal[
"external_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
] | None = None
default_visibility: Literal["tenant", "restricted"] | None = None
default_acl_tokens: list[str] | None = Field(default=None, max_length=200)
routes: ServiceDeskRouteMapping | None = None
queue_mappings: list[ServiceDeskQueueMapping] | None = Field(
default=None, max_length=500
)
dynamic_field_mappings: list[ServiceDeskDynamicFieldMapping] | None = Field(
default=None, max_length=500
)
class ServiceDeskDiagnostic(BaseModel):
severity: Literal["info", "warning", "error"]
code: str
message: str
object_ref: str | None = None
field: str | None = None
retryable: bool = False
details: dict[str, Any] = Field(default_factory=dict)
class ServiceDeskProfileItem(BaseModel):
id: str
tenant_id: str
configuration_id: str
status: str
integration_mode: str
product: str
product_version: str | None = None
desired_maturity: str
discovered_maturity: str
source_authority_mode: str
default_visibility: str
default_acl_tokens: list[str]
routes: ServiceDeskRouteMapping
queue_mappings: list[ServiceDeskQueueMapping]
dynamic_field_mappings: list[ServiceDeskDynamicFieldMapping]
capabilities: list[str]
discovery_revision: str | None = None
discovered_configuration_revision: int | None = None
discovered_configuration_hash: str | None = None
health_status: str
health_details: dict[str, Any]
discovered_at: datetime | None = None
last_sync_cursor: str | None = None
last_high_watermark: str | None = None
resource_revision: int
credential_reference_present: bool
endpoint_configured: bool
created_at: datetime
updated_at: datetime
class ServiceDeskProfileListResponse(BaseModel):
items: list[ServiceDeskProfileItem]
class ServiceDeskDiscoveryResponse(BaseModel):
profile: ServiceDeskProfileItem
product: str
product_version: str | None = None
api_family: str
capabilities: list[str]
maturity: str
health_status: str
diagnostics: list[ServiceDeskDiagnostic]
revision: str
class ServiceDeskSyncRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
idempotency_key: str = Field(min_length=1, max_length=255)
mode: Literal["auto", "full", "delta"] = "auto"
cursor: str | None = Field(default=None, max_length=4000)
limit: int = Field(default=100, ge=1, le=500)
class ServiceDeskObjectItem(BaseModel):
id: str
profile_id: str
object_type: str
external_id: str
external_ticket_number: str | None = None
title: str
canonical_url: str | None = None
status: str
source_revision: str
visibility: str
acl_tokens: list[str]
mapped_data: dict[str, Any]
provenance: dict[str, Any]
source_updated_at: datetime | None = None
observed_at: datetime
resource_revision: int
class ServiceDeskObjectListResponse(BaseModel):
items: list[ServiceDeskObjectItem]
next_cursor: str | None = None
class ServiceDeskSyncRunItem(BaseModel):
id: str
profile_id: str
mode: str
idempotency_key: str
status: str
cursor_before: str | None = None
cursor_after: str | None = None
high_watermark: str | None = None
counts: dict[str, int]
effects: list[dict[str, Any]]
diagnostics: list[ServiceDeskDiagnostic]
provenance: dict[str, Any]
started_at: datetime
finished_at: datetime | None = None
created_at: datetime
class ServiceDeskSyncRunListResponse(BaseModel):
items: list[ServiceDeskSyncRunItem]
class ServiceDeskTicketUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
idempotency_key: str = Field(min_length=1, max_length=255)
expected_external_revision: str = Field(min_length=1, max_length=255)
title: str | None = Field(default=None, min_length=1, max_length=500)
queue: str | None = Field(default=None, min_length=1, max_length=300)
state: str | None = Field(default=None, min_length=1, max_length=200)
priority: str | None = Field(default=None, min_length=1, max_length=200)
owner: str | None = Field(default=None, min_length=1, max_length=255)
responsible: str | None = Field(default=None, min_length=1, max_length=255)
dynamic_fields: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def require_change(self) -> "ServiceDeskTicketUpdateRequest":
if not any(
(
self.title,
self.queue,
self.state,
self.priority,
self.owner,
self.responsible,
self.dynamic_fields,
)
):
raise ValueError("At least one supported ticket field must change")
if len(self.dynamic_fields) > 100:
raise ValueError("At most 100 dynamic fields may be updated")
return self
class ServiceDeskTicketUpdateResponse(BaseModel):
run: ServiceDeskSyncRunItem
object: ServiceDeskObjectItem
accepted: bool
outcome_unknown: bool
def _validate_profile_policy(
*,
integration_mode: str,
authority_mode: str,
visibility: str,
acl_tokens: list[str],
) -> None:
allowed = {
"link": {"linked_reference"},
"import": {"external_authoritative", "external_mirror"},
"synchronize": {"external_authoritative", "governed_sync"},
}
if authority_mode not in allowed[integration_mode]:
raise ValueError(
f"{integration_mode} mode does not support {authority_mode} authority"
)
if visibility == "restricted" and not acl_tokens:
raise ValueError("Restricted profiles require at least one ACL token")
def _credential_query_keys(query: str) -> set[str]:
return {
str(key).strip().casefold()
for key, _value in parse_qsl(query, keep_blank_values=True)
if str(key).strip().casefold() in _CREDENTIAL_CONTROL_KEYS
}
__all__ = [name for name in globals() if name.startswith("ServiceDesk")]
@@ -0,0 +1,337 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_connectors.backend.db.models import (
ConnectorConfiguration,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
)
from govoplan_connectors.backend.service_desk_connector import (
SERVICE_DESK_PROVIDER_ID,
SERVICE_DESK_READ_SCOPE,
SERVICE_DESK_RESOURCE_TYPE,
)
_SEARCH_MATURITIES = ("search", "read", "publish", "synchronize", "migrate", "replace")
class ExternalServiceDeskSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=SERVICE_DESK_PROVIDER_ID,
module_id="connectors",
resource_type=SERVICE_DESK_RESOURCE_TYPE,
label="External service-desk tickets",
requires_authorization_recheck=True,
),
)
def backfill(
self, session: object, *, request: SearchBackfillRequest
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
query = (
select(ConnectorServiceDeskObject, ConnectorServiceDeskProfile)
.join(
ConnectorServiceDeskProfile,
ConnectorServiceDeskProfile.id == ConnectorServiceDeskObject.profile_id,
)
.join(
ConnectorConfiguration,
ConnectorConfiguration.id
== ConnectorServiceDeskProfile.configuration_id,
)
.where(
ConnectorServiceDeskObject.tenant_id == request.tenant_id,
ConnectorServiceDeskObject.object_type == "ticket",
ConnectorServiceDeskObject.status != "deleted",
ConnectorServiceDeskProfile.tenant_id == request.tenant_id,
ConnectorServiceDeskProfile.status == "active",
ConnectorConfiguration.tenant_id == request.tenant_id,
ConnectorConfiguration.status == "active",
ConnectorServiceDeskProfile.discovered_configuration_revision
== ConnectorConfiguration.resource_revision,
ConnectorServiceDeskProfile.discovered_configuration_hash
== ConnectorConfiguration.effective_hash,
ConnectorServiceDeskProfile.desired_maturity.in_(_SEARCH_MATURITIES),
ConnectorServiceDeskProfile.discovered_maturity.in_(_SEARCH_MATURITIES),
)
)
if request.cursor:
query = query.where(ConnectorServiceDeskObject.id > request.cursor)
rows = tuple(
db.execute(
query.order_by(ConnectorServiceDeskObject.id.asc()).limit(request.limit + 1)
)
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
watermark = db.scalar(
select(func.max(ConnectorServiceDeskObject.updated_at))
.join(
ConnectorServiceDeskProfile,
ConnectorServiceDeskProfile.id == ConnectorServiceDeskObject.profile_id,
)
.join(
ConnectorConfiguration,
ConnectorConfiguration.id
== ConnectorServiceDeskProfile.configuration_id,
)
.where(
ConnectorServiceDeskObject.tenant_id == request.tenant_id,
ConnectorServiceDeskObject.object_type == "ticket",
ConnectorServiceDeskObject.status != "deleted",
ConnectorServiceDeskProfile.tenant_id == request.tenant_id,
ConnectorServiceDeskProfile.status == "active",
ConnectorConfiguration.tenant_id == request.tenant_id,
ConnectorConfiguration.status == "active",
ConnectorServiceDeskProfile.discovered_configuration_revision
== ConnectorConfiguration.resource_revision,
ConnectorServiceDeskProfile.discovered_configuration_hash
== ConnectorConfiguration.effective_hash,
ConnectorServiceDeskProfile.desired_maturity.in_(_SEARCH_MATURITIES),
ConnectorServiceDeskProfile.discovered_maturity.in_(_SEARCH_MATURITIES),
)
)
return SearchBackfillPage(
documents=tuple(search_document(db, row, profile) for row, profile in selected),
next_cursor=selected[-1][0].id if has_more and selected else None,
complete=not has_more,
high_watermark=watermark.isoformat() if watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
can_read = _has_scope(principal, SERVICE_DESK_READ_SCOPE)
tokens = set(_principal_acl_tokens(principal))
decisions = {item.reference.key: False for item in requests}
if not tenant_id or not can_read:
return decisions
for request in requests:
reference = request.reference
if (
reference.tenant_id != tenant_id
or reference.module_id != "connectors"
or reference.resource_type != SERVICE_DESK_RESOURCE_TYPE
):
continue
joined = db.execute(
select(ConnectorServiceDeskObject, ConnectorServiceDeskProfile)
.join(
ConnectorServiceDeskProfile,
ConnectorServiceDeskProfile.id
== ConnectorServiceDeskObject.profile_id,
)
.join(
ConnectorConfiguration,
ConnectorConfiguration.id
== ConnectorServiceDeskProfile.configuration_id,
)
.where(
ConnectorServiceDeskObject.tenant_id == tenant_id,
ConnectorServiceDeskObject.id == reference.resource_id,
ConnectorServiceDeskObject.object_type == "ticket",
ConnectorServiceDeskObject.status != "deleted",
ConnectorServiceDeskProfile.tenant_id == tenant_id,
ConnectorServiceDeskProfile.status == "active",
ConnectorConfiguration.tenant_id == tenant_id,
ConnectorConfiguration.status == "active",
ConnectorServiceDeskProfile.discovered_configuration_revision
== ConnectorConfiguration.resource_revision,
ConnectorServiceDeskProfile.discovered_configuration_hash
== ConnectorConfiguration.effective_hash,
ConnectorServiceDeskProfile.desired_maturity.in_(_SEARCH_MATURITIES),
ConnectorServiceDeskProfile.discovered_maturity.in_(_SEARCH_MATURITIES),
)
).first()
if joined is None:
continue
row, _profile = joined
decisions[reference.key] = row.visibility == "tenant" or bool(
tokens.intersection(str(value) for value in row.acl_tokens or ())
)
return decisions
def create_external_service_desk_search_source(
_context: ModuleContext,
) -> ExternalServiceDeskSearchSource:
return ExternalServiceDeskSearchSource()
def search_document(
session: Session,
row: ConnectorServiceDeskObject,
profile: ConnectorServiceDeskProfile | None = None,
) -> SearchDocument:
if profile is None:
profile = session.scalar(
select(ConnectorServiceDeskProfile).where(
ConnectorServiceDeskProfile.tenant_id == row.tenant_id,
ConnectorServiceDeskProfile.id == row.profile_id,
)
)
if profile is None:
raise ValueError("External service-desk profile is unavailable.")
data = dict(row.mapped_data or {})
queue = data.get("queue") if isinstance(data.get("queue"), Mapping) else {}
state = data.get("state") if isinstance(data.get("state"), Mapping) else {}
priority = data.get("priority") if isinstance(data.get("priority"), Mapping) else {}
articles = [value for value in data.get("articles") or () if isinstance(value, Mapping)]
article_subjects = tuple(
str(value.get("subject") or "")[:500]
for value in articles
if value.get("subject")
)
article_body = "\n\n".join(
str(value.get("body") or "") for value in articles if value.get("body")
)[:200_000]
dynamic_fields = data.get("dynamic_fields")
dynamic_keywords = tuple(
f"{key}:{str(value)[:200]}"
for key, value in (
dynamic_fields.items() if isinstance(dynamic_fields, Mapping) else ()
)
)
external_reference = ExternalObjectReference(
system=profile.product if profile.product != "unknown" else "znuny_otrs",
object_type=row.object_type,
object_id=row.external_id,
maturity=profile.discovered_maturity,
authority_mode=profile.source_authority_mode,
connector_id=profile.id,
canonical_url=row.canonical_url,
version=row.source_revision,
etag=row.content_hash,
observed_at=row.observed_at,
metadata={
"ticket_number": row.external_ticket_number,
"title": row.title,
"queue": queue.get("name"),
"state": state.get("name"),
},
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="connectors",
provider_id=SERVICE_DESK_PROVIDER_ID,
resource_type=SERVICE_DESK_RESOURCE_TYPE,
resource_id=row.id,
title=(
f"{row.external_ticket_number}: {row.title}"
if row.external_ticket_number
else row.title
),
url=(
"/connectors/service-desk?profileId="
f"{quote(row.profile_id, safe='')}&objectId={quote(row.id, safe='')}"
),
summary=(article_subjects[0] if article_subjects else None),
body=article_body or None,
keywords=tuple(
dict.fromkeys(
value
for value in (
str(queue.get("name") or ""),
str(state.get("name") or ""),
str(priority.get("name") or ""),
*article_subjects,
*dynamic_keywords,
)
if value
)
)[:100],
visibility=row.visibility,
acl_tokens=(
tuple(str(value) for value in row.acl_tokens or ())
if row.visibility == "restricted"
else ()
),
external_reference=external_reference,
metadata={
"profile_id": row.profile_id,
"external_ticket_id": row.external_id,
"external_ticket_number": row.external_ticket_number,
"target_queue_ref": data.get("target_queue_ref"),
"integration_mode": profile.integration_mode,
"source_authority_mode": profile.source_authority_mode,
"status": row.status,
},
source_revision=row.source_revision,
change_cursor=row.change_cursor,
source_updated_at=row.source_updated_at or row.observed_at,
requires_authorization_recheck=True,
)
def _principal_acl_tokens(principal: object) -> tuple[str, ...]:
values: list[str] = []
for prefix, attribute in (
("account", "account_id"),
("membership", "membership_id"),
("identity", "identity_id"),
):
value = getattr(principal, attribute, None)
if value:
values.append(f"{prefix}:{value}")
for prefix, attribute in (
("group", "group_ids"),
("role", "role_ids"),
("function", "function_assignment_ids"),
("scope", "scopes"),
):
values.extend(
f"{prefix}:{value}"
for value in getattr(principal, attribute, ())
if value
)
return tuple(dict.fromkeys(values))[:500]
def _has_scope(principal: object, required: str) -> bool:
check = getattr(principal, "has", None)
if callable(check):
return bool(check(required))
return required in getattr(principal, "scopes", ())
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != SERVICE_DESK_PROVIDER_ID or resource_type != SERVICE_DESK_RESOURCE_TYPE:
raise ValueError("Unsupported external service-desk Search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("External service-desk Search requires a SQLAlchemy session.")
return value
__all__ = [
"ExternalServiceDeskSearchSource",
"create_external_service_desk_search_source",
"search_document",
]
@@ -0,0 +1,896 @@
from __future__ import annotations
import hashlib
import json
import socket
import urllib.error
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Protocol
from urllib.parse import quote, urlencode, urljoin, urlsplit
from govoplan_core.security.http_fetch import HttpFetchResponse, fetch_http
from govoplan_core.security.outbound_http import OutboundHttpError
MAX_SERVICE_DESK_RESPONSE_BYTES = 10_000_000
MAX_SERVICE_DESK_SEARCH_IDS = 10_000
MAX_SERVICE_DESK_TICKET_READS = 500
SERVICE_DESK_SENSITIVE_HEADERS = (
"X-OTRS-Header-UserLogin",
"X-OTRS-Header-CustomerUserLogin",
"X-OTRS-Header-Password",
"X-OTRS-Header-SessionID",
)
class ServiceDeskTransportError(RuntimeError):
def __init__(
self,
code: str,
message: str,
*,
retryable: bool = False,
outcome_unknown: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.retryable = retryable
self.outcome_unknown = outcome_unknown
@dataclass(frozen=True, slots=True)
class ServiceDeskChangeBatch:
changes: tuple[Mapping[str, Any], ...]
next_cursor: str | None
complete: bool
high_watermark: str | None
live_ids: tuple[str, ...] | None
evidence: Mapping[str, Any]
@dataclass(frozen=True, slots=True)
class ServiceDeskUpdateResult:
ticket: Mapping[str, Any]
revision: str
evidence: Mapping[str, Any]
class ServiceDeskTransport(Protocol):
def discover(
self,
*,
endpoint_url: str,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
) -> Mapping[str, Any]: ...
def changes(
self,
*,
endpoint_url: str,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
cursor: str | None,
limit: int,
force_full: bool,
) -> ServiceDeskChangeBatch: ...
def update_ticket(
self,
*,
endpoint_url: str,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
ticket_id: str,
expected_revision: str,
changes: Mapping[str, Any],
) -> ServiceDeskUpdateResult: ...
class HttpServiceDeskTransport:
"""Bounded Znuny/OTRS GenericInterface REST transport.
GenericInterface route names are administrator-defined. The governed profile
supplies the paths and methods while this adapter enforces outbound policy,
response bounds, credential placement, cursor stability, and write recovery
semantics.
"""
def discover(
self,
*,
endpoint_url: str,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
) -> Mapping[str, Any]:
payload, response = self._search(
endpoint_url,
credential=credential,
routes=routes,
criteria={"Limit": 1, "SortBy": ["Changed"], "OrderBy": ["Up"]},
)
product, version = _product_version(payload, response.headers, endpoint_url)
recognized = product in {"znuny", "otrs"} and _major(version) >= 6
capabilities = ["discover", "link", "search", "read"]
if recognized:
capabilities.append("synchronize")
if recognized and routes.get("update_path"):
capabilities.append("publish")
maturity = "synchronize" if "synchronize" in capabilities else "read"
revision = _hash(
{
"product": product,
"version": version,
"routes": dict(routes),
"capabilities": capabilities,
"status": response.status,
}
)
return {
"product": product,
"product_version": version,
"api_family": "generic_interface_rest",
"capabilities": capabilities,
"maturity": maturity,
"health_status": "healthy",
"revision": revision,
"diagnostics": (
[]
if recognized
else [
{
"severity": "warning",
"code": "product_version_unverified",
"message": (
"The GenericInterface endpoint is healthy, but its product "
"and major version could not be verified; maturity is limited to read."
),
"retryable": False,
"details": {},
}
]
),
"evidence": {
"http_status": response.status,
"response_content_type": response.headers.get("Content-Type"),
"ticket_search_shape": _search_shape(payload),
"credential_present": bool(credential),
},
}
def changes(
self,
*,
endpoint_url: str,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
cursor: str | None,
limit: int,
force_full: bool,
) -> ServiceDeskChangeBatch:
if limit < 1 or limit > MAX_SERVICE_DESK_TICKET_READS:
raise ServiceDeskTransportError(
"read_limit_invalid",
"A service-desk synchronization call must request between 1 and 500 tickets.",
)
if force_full:
return self._full_changes(
endpoint_url,
credential=credential,
routes=routes,
cursor=cursor,
limit=limit,
)
return self._delta_changes(
endpoint_url,
credential=credential,
routes=routes,
cursor=cursor,
limit=limit,
)
def update_ticket(
self,
*,
endpoint_url: str,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
ticket_id: str,
expected_revision: str,
changes: Mapping[str, Any],
) -> ServiceDeskUpdateResult:
update_path = _optional_text(routes.get("update_path"))
if not update_path:
raise ServiceDeskTransportError(
"update_unsupported",
"The configured GenericInterface profile has no ticket update route.",
)
current, _response = self._ticket(
endpoint_url,
credential=credential,
routes=routes,
ticket_id=ticket_id,
)
actual_revision = _ticket_revision(current)
if actual_revision != expected_revision:
raise ServiceDeskTransportError(
"external_revision_conflict",
"The external ticket changed; synchronize it before updating.",
)
url = _route_url(
endpoint_url,
update_path.replace("{ticket_id}", quote(ticket_id, safe="")),
)
method = str(routes.get("update_method") or "PATCH").upper()
request_payload = _authenticated_payload(
{"Ticket": dict(changes)}, credential
)
try:
_payload, response = self._request(
url,
method=method,
credential=credential,
payload=request_payload,
mutation=True,
)
except ServiceDeskTransportError as exc:
if exc.outcome_unknown:
raise
raise ServiceDeskTransportError(
exc.code,
str(exc),
retryable=exc.retryable,
outcome_unknown=False,
) from exc
refreshed, read_response = self._ticket(
endpoint_url,
credential=credential,
routes=routes,
ticket_id=ticket_id,
)
if _ticket_id(refreshed) != ticket_id:
raise ServiceDeskTransportError(
"update_verification_identity_mismatch",
"The provider verification returned another ticket identity.",
outcome_unknown=True,
)
revision = _ticket_revision(refreshed)
if revision == expected_revision:
raise ServiceDeskTransportError(
"update_verification_failed",
"The provider accepted the request but the ticket revision did not change.",
outcome_unknown=True,
)
mismatches = _update_mismatches(refreshed, changes)
if mismatches:
raise ServiceDeskTransportError(
"update_verification_failed",
"The provider revision changed, but the requested ticket fields could not be verified.",
outcome_unknown=True,
)
return ServiceDeskUpdateResult(
ticket=refreshed,
revision=revision,
evidence={
"update_http_status": response.status,
"verification_http_status": read_response.status,
"previous_revision": expected_revision,
"accepted_revision": revision,
"verified_fields": sorted(changes),
},
)
def _full_changes(
self,
endpoint_url: str,
*,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
cursor: str | None,
limit: int,
) -> ServiceDeskChangeBatch:
payload, _response = self._search(
endpoint_url,
credential=credential,
routes=routes,
criteria={
"Limit": MAX_SERVICE_DESK_SEARCH_IDS + 1,
"SortBy": ["TicketID"],
"OrderBy": ["Up"],
},
)
ids = _ticket_ids(payload)
_assert_search_bound(ids)
fingerprint = _hash(ids)
state = _decode_cursor(cursor, expected_kind="full")
offset = int(state.get("offset") or 0)
if offset > len(ids):
raise ServiceDeskTransportError(
"full_cursor_stale",
"The full synchronization cursor is beyond the current ticket set; restart the backfill.",
)
if state.get("fingerprint") is not None and state.get("fingerprint") != fingerprint:
raise ServiceDeskTransportError(
"full_cursor_stale",
"The external ticket set changed during backfill; restart the full synchronization.",
)
selected = ids[offset : offset + limit]
changes = tuple(
self._ticket(
endpoint_url,
credential=credential,
routes=routes,
ticket_id=ticket_id,
)[0]
for ticket_id in selected
)
new_offset = offset + len(selected)
complete = new_offset >= len(ids)
page_high_watermark = _latest_revision(changes)
high_watermark = max(
value
for value in (
_optional_text(state.get("high_watermark")),
page_high_watermark,
)
if value is not None
) if (_optional_text(state.get("high_watermark")) or page_high_watermark) else None
next_cursor = (
None
if complete
else _encode_cursor(
{
"kind": "full",
"offset": new_offset,
"fingerprint": fingerprint,
"high_watermark": high_watermark,
}
)
)
return ServiceDeskChangeBatch(
changes=changes,
next_cursor=next_cursor,
complete=complete,
high_watermark=high_watermark,
live_ids=tuple(ids) if complete else None,
evidence={
"mode": "backfill",
"available": len(ids),
"offset": offset,
"returned": len(changes),
"ticket_set_fingerprint": fingerprint,
},
)
def _delta_changes(
self,
endpoint_url: str,
*,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
cursor: str | None,
limit: int,
) -> ServiceDeskChangeBatch:
state = _decode_cursor(cursor, expected_kind="delta")
changed = _optional_text(state.get("changed"))
seen = {str(value) for value in state.get("seen") or ()}
search_limit = min(
MAX_SERVICE_DESK_TICKET_READS,
limit + len(seen) + 1,
)
criteria: dict[str, Any] = {
"Limit": search_limit,
"SortBy": ["Changed"],
"OrderBy": ["Up"],
}
if changed:
criteria["TicketChangeTimeNewerDate"] = _overlap_boundary(changed)
payload, _response = self._search(
endpoint_url,
credential=credential,
routes=routes,
criteria=criteria,
)
ids = _ticket_ids(payload)
_assert_search_bound(ids)
fetched = tuple(
self._ticket(
endpoint_url,
credential=credential,
routes=routes,
ticket_id=ticket_id,
)[0]
for ticket_id in ids
)
eligible: list[Mapping[str, Any]] = []
suppressed = 0
for ticket in fetched:
revision = _ticket_revision(ticket)
ticket_id = _ticket_id(ticket)
if changed and (
revision < changed or (revision == changed and ticket_id in seen)
):
suppressed += 1
continue
eligible.append(ticket)
ordered_eligible = sorted(
eligible,
key=lambda item: (_ticket_revision(item), _ticket_id(item)),
)
ordered = ordered_eligible[:limit]
if not ordered and len(ids) >= search_limit:
raise ServiceDeskTransportError(
"delta_boundary_overflow",
"The overlap window contains too many tickets to advance safely; narrow the profile or run a full synchronization.",
)
latest = _latest_revision(ordered) or changed
latest_seen = set()
if latest:
if latest == changed:
latest_seen.update(seen)
latest_seen.update(
_ticket_id(item)
for item in ordered
if _ticket_revision(item) == latest
)
complete = len(ordered_eligible) <= limit and len(ids) < search_limit
next_cursor = _encode_cursor(
{
"kind": "delta",
"changed": latest,
"seen": sorted(latest_seen),
}
)
return ServiceDeskChangeBatch(
changes=tuple(ordered),
next_cursor=next_cursor,
complete=complete,
high_watermark=latest,
live_ids=None,
evidence={
"mode": "delta",
"searched": len(ids),
"returned": len(ordered),
"overlap_suppressed": suppressed,
"search_limit": search_limit,
},
)
def _search(
self,
endpoint_url: str,
*,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
criteria: Mapping[str, Any],
) -> tuple[Mapping[str, Any], HttpFetchResponse]:
url = _route_url(endpoint_url, str(routes.get("search_path") or "/Ticket/Search"))
method = str(routes.get("search_method") or "POST").upper()
configured_filters = routes.get("search_filters")
if configured_filters is None:
configured_filters = {}
if not isinstance(configured_filters, Mapping):
raise ServiceDeskTransportError(
"search_filters_invalid",
"Governed GenericInterface search filters must be a JSON object.",
)
return self._request(
url,
method=method,
credential=credential,
payload=_authenticated_payload(
{**dict(configured_filters), **dict(criteria)}, credential
),
)
def _ticket(
self,
endpoint_url: str,
*,
credential: Mapping[str, Any] | None,
routes: Mapping[str, Any],
ticket_id: str,
) -> tuple[Mapping[str, Any], HttpFetchResponse]:
path = str(routes.get("ticket_path") or "/Ticket/{ticket_id}").replace(
"{ticket_id}", quote(ticket_id, safe="")
)
method = str(routes.get("ticket_method") or "GET").upper()
identity_only = bool(routes.get("_identity_only"))
payload: dict[str, Any] = {
"TicketID": ticket_id,
"DynamicFields": 0 if identity_only else 1,
"Extended": 1,
"AllArticles": 0 if identity_only else 1,
"Attachments": 0 if identity_only else 1,
"GetAttachmentContents": 0,
}
raw, response = self._request(
_route_url(endpoint_url, path),
method=method,
credential=credential,
payload=_authenticated_payload(payload, credential),
)
return _ticket_payload(raw), response
def _request(
self,
url: str,
*,
method: str,
credential: Mapping[str, Any] | None,
payload: Mapping[str, Any] | None,
mutation: bool = False,
) -> tuple[Mapping[str, Any], HttpFetchResponse]:
headers = {"Accept": "application/json", **_auth_headers(credential)}
body: bytes | None = None
request_url = url
if payload:
if method == "GET":
secret_keys = {
"SessionID",
"UserLogin",
"CustomerUserLogin",
"Password",
}
if secret_keys.intersection(payload):
raise ServiceDeskTransportError(
"credential_transport_unsafe",
"Body authentication cannot be used with a GET route; use header authentication or configure a POST route.",
)
separator = "&" if urlsplit(request_url).query else "?"
request_url = f"{request_url}{separator}{urlencode(payload, doseq=True)}"
else:
headers["Content-Type"] = "application/json"
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
try:
response = fetch_http(
request_url,
timeout=20,
label="Service-desk connector URL",
method=method,
headers=headers,
body=body,
max_bytes=MAX_SERVICE_DESK_RESPONSE_BYTES,
redirect_sensitive_headers=SERVICE_DESK_SENSITIVE_HEADERS,
)
except urllib.error.HTTPError as exc:
retryable = exc.code == 429 or exc.code >= 500
raise ServiceDeskTransportError(
"provider_http_error",
f"The service-desk provider returned HTTP {exc.code}.",
retryable=retryable,
outcome_unknown=mutation and retryable,
) from exc
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
raise ServiceDeskTransportError(
"provider_unavailable",
"The service-desk provider did not return a conclusive response.",
retryable=True,
outcome_unknown=mutation,
) from exc
except (ValueError, OutboundHttpError) as exc:
raise ServiceDeskTransportError(
"transport_policy_rejected", str(exc), retryable=False
) from exc
try:
decoded = json.loads(response.body.decode("utf-8")) if response.body else {}
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ServiceDeskTransportError(
"invalid_provider_response",
"The service-desk provider did not return valid JSON.",
outcome_unknown=mutation,
) from exc
if not isinstance(decoded, Mapping):
raise ServiceDeskTransportError(
"invalid_provider_response",
"The service-desk provider response must be a JSON object.",
outcome_unknown=mutation,
)
error = decoded.get("Error")
if isinstance(error, Mapping):
code = _optional_text(error.get("ErrorCode")) or "provider_rejected"
message = _optional_text(error.get("ErrorMessage")) or "Provider rejected the request."
raise ServiceDeskTransportError(code, message, retryable=False)
return decoded, response
def _route_url(endpoint_url: str, route: str) -> str:
parsed = urlsplit(route)
if parsed.scheme or parsed.netloc or parsed.username or parsed.password:
raise ServiceDeskTransportError(
"route_invalid", "GenericInterface routes must be relative to the governed endpoint."
)
if any(part == ".." for part in parsed.path.split("/")):
raise ServiceDeskTransportError(
"route_invalid", "GenericInterface routes cannot traverse parent paths."
)
return urljoin(endpoint_url.rstrip("/") + "/", route.lstrip("/"))
def _auth_headers(credential: Mapping[str, Any] | None) -> dict[str, str]:
if not credential:
return {}
if str(credential.get("auth_mode") or "header").casefold() == "body":
return {}
headers: dict[str, str] = {}
session_id = _credential_value(credential, "session_id", "SessionID")
user_login = _credential_value(credential, "user_login", "UserLogin", "username")
password = _credential_value(credential, "password", "Password")
customer_login = _credential_value(
credential, "customer_user_login", "CustomerUserLogin"
)
if session_id:
headers["X-OTRS-Header-SessionID"] = session_id
if user_login:
headers["X-OTRS-Header-UserLogin"] = user_login
if customer_login:
headers["X-OTRS-Header-CustomerUserLogin"] = customer_login
if password:
headers["X-OTRS-Header-Password"] = password
return headers
def _authenticated_payload(
payload: Mapping[str, Any], credential: Mapping[str, Any] | None
) -> dict[str, Any]:
result = dict(payload)
if (
not credential
or str(credential.get("auth_mode") or "header").casefold() != "body"
):
return result
for target, aliases in (
("SessionID", ("session_id", "SessionID")),
("UserLogin", ("user_login", "UserLogin", "username")),
("CustomerUserLogin", ("customer_user_login", "CustomerUserLogin")),
("Password", ("password", "Password")),
):
value = _credential_value(credential, *aliases)
if value:
result[target] = value
return result
def _credential_value(credential: Mapping[str, Any], *keys: str) -> str | None:
for key in keys:
value = _optional_text(credential.get(key))
if value:
return value
return None
def _ticket_ids(payload: Mapping[str, Any]) -> list[str]:
raw = payload.get("TicketID")
if raw is None:
raw = payload.get("TicketIDs")
if raw is None:
raw = payload.get("TicketId")
if raw is None:
return []
values: Sequence[Any] = raw if isinstance(raw, Sequence) and not isinstance(raw, str) else [raw]
return list(dict.fromkeys(str(value).strip() for value in values if str(value).strip()))
def _ticket_payload(payload: Mapping[str, Any]) -> Mapping[str, Any]:
raw = payload.get("Ticket")
if isinstance(raw, Mapping):
return raw
if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
for value in raw:
if isinstance(value, Mapping):
return value
if any(key in payload for key in ("TicketID", "TicketNumber", "Title", "Changed")):
return payload
raise ServiceDeskTransportError(
"ticket_response_invalid", "The provider response did not contain a ticket object."
)
def _product_version(
payload: Mapping[str, Any], headers: Mapping[str, str], endpoint_url: str
) -> tuple[str, str | None]:
normalized_headers = {key.casefold(): value for key, value in headers.items()}
znuny_version = _optional_text(normalized_headers.get("x-znuny-version"))
otrs_version = _optional_text(normalized_headers.get("x-otrs-version"))
product = _optional_text(payload.get("Product"))
version = _optional_text(payload.get("Version"))
system_data = payload.get("SystemData")
if isinstance(system_data, Mapping):
product = product or _optional_text(system_data.get("Product"))
version = version or _optional_text(system_data.get("Version"))
if znuny_version:
return "znuny", znuny_version
if otrs_version:
return "otrs", otrs_version
folded = str(product or "").casefold()
if "znuny" in folded:
return "znuny", version
if "otrs" in folded:
return "otrs", version
path = urlsplit(endpoint_url).path.casefold()
if "/znuny/" in path:
return "znuny", version
if "/otrs/" in path:
return "otrs", version
return "znuny_otrs", version
def _major(version: str | None) -> int:
if not version:
return 0
head = version.strip().split(".", 1)[0]
return int(head) if head.isdigit() else 0
def _ticket_id(ticket: Mapping[str, Any]) -> str:
value = _optional_text(ticket.get("TicketID")) or _optional_text(ticket.get("ID"))
if not value:
raise ServiceDeskTransportError(
"ticket_identity_missing", "The provider ticket has no stable TicketID."
)
return value
def _ticket_revision(ticket: Mapping[str, Any]) -> str:
value = (
_optional_text(ticket.get("Changed"))
or _optional_text(ticket.get("ChangeTime"))
or _optional_text(ticket.get("Updated"))
)
if value:
return value
return _hash(ticket)
def _update_mismatches(
ticket: Mapping[str, Any],
changes: Mapping[str, Any],
) -> list[str]:
mismatches: list[str] = []
for field in ("Title", "Queue", "State", "Priority", "Owner", "Responsible"):
if field in changes and ticket.get(field) != changes[field]:
mismatches.append(field)
expected_dynamic = changes.get("DynamicField")
if isinstance(expected_dynamic, Sequence) and not isinstance(
expected_dynamic, (str, bytes)
):
actual_dynamic = _dynamic_field_values(ticket)
for item in expected_dynamic:
if not isinstance(item, Mapping):
mismatches.append("DynamicField")
continue
name = _optional_text(item.get("Name"))
if not name or name not in actual_dynamic or actual_dynamic[name] != item.get("Value"):
mismatches.append(f"DynamicField.{name or 'unknown'}")
return mismatches
def _dynamic_field_values(ticket: Mapping[str, Any]) -> dict[str, Any]:
raw = (
ticket.get("DynamicField")
if ticket.get("DynamicField") is not None
else ticket.get("DynamicFields")
)
if isinstance(raw, Mapping):
return {str(key): value for key, value in raw.items()}
result: dict[str, Any] = {}
if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
for item in raw:
if not isinstance(item, Mapping):
continue
name = _optional_text(item.get("Name"))
if name:
result[name] = item.get("Value")
return result
def _latest_revision(changes: Sequence[Mapping[str, Any]]) -> str | None:
values = [_ticket_revision(item) for item in changes]
return max(values) if values else None
def _overlap_boundary(value: str) -> str:
normalized = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return (parsed.astimezone(timezone.utc) - timedelta(seconds=1)).isoformat().replace(
"+00:00", "Z"
)
def _assert_search_bound(ids: Sequence[str]) -> None:
if len(ids) > MAX_SERVICE_DESK_SEARCH_IDS:
raise ServiceDeskTransportError(
"search_result_unbounded",
"The provider returned more than 10000 ticket identities; narrow the profile by queue.",
)
def _decode_cursor(cursor: str | None, *, expected_kind: str) -> dict[str, Any]:
if not cursor:
return {"kind": expected_kind}
try:
value = json.loads(cursor)
except json.JSONDecodeError as exc:
raise ServiceDeskTransportError("cursor_invalid", "The synchronization cursor is invalid.") from exc
if not isinstance(value, Mapping) or value.get("kind") != expected_kind:
raise ServiceDeskTransportError(
"cursor_mode_mismatch", "The synchronization cursor belongs to another mode."
)
decoded = dict(value)
if expected_kind == "full":
offset = decoded.get("offset", 0)
fingerprint = decoded.get("fingerprint")
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
raise ServiceDeskTransportError(
"cursor_invalid", "The full synchronization cursor offset is invalid."
)
if offset and (
not isinstance(fingerprint, str)
or len(fingerprint) != 64
or any(
character not in "0123456789abcdef"
for character in fingerprint.casefold()
)
):
raise ServiceDeskTransportError(
"cursor_invalid", "The full synchronization cursor fingerprint is invalid."
)
elif expected_kind == "delta":
changed = decoded.get("changed")
seen = decoded.get("seen", [])
if changed is not None and not isinstance(changed, str):
raise ServiceDeskTransportError(
"cursor_invalid", "The delta synchronization boundary is invalid."
)
if not isinstance(seen, list) or any(
not isinstance(item, str) or not item for item in seen
):
raise ServiceDeskTransportError(
"cursor_invalid", "The delta synchronization identity set is invalid."
)
return decoded
def _encode_cursor(value: Mapping[str, Any]) -> str:
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"))
if len(encoded) > 4000:
raise ServiceDeskTransportError(
"cursor_boundary_overflow",
"Too many tickets share the same change boundary; narrow the profile by queue.",
)
return encoded
def _search_shape(payload: Mapping[str, Any]) -> str:
if "TicketID" in payload:
return "TicketID"
if "TicketIDs" in payload:
return "TicketIDs"
return "empty"
def _hash(value: Any) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
).hexdigest()
def _optional_text(value: object) -> str | None:
normalized = str(value).strip() if value is not None else ""
return normalized or None
__all__ = [
"HttpServiceDeskTransport",
"ServiceDeskChangeBatch",
"ServiceDeskTransport",
"ServiceDeskTransportError",
"ServiceDeskUpdateResult",
]