From b277e8d7acc45ac5984be8c06bb0ff62faa31a47 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 16:11:07 +0200 Subject: [PATCH] Release Connectors v0.1.22 with service-desk federation --- README.md | 11 + docs/ZNUNY_OTRS_CONNECTOR.md | 188 ++ pyproject.toml | 4 +- src/govoplan_connectors/backend/db/models.py | 184 ++ .../backend/dsar_provider.py | 100 + src/govoplan_connectors/backend/manifest.py | 245 +- ...f1a2b3c4d5_znuny_service_desk_connector.py | 201 ++ .../backend/provider_state.py | 184 ++ src/govoplan_connectors/backend/router.py | 226 ++ .../backend/service_desk_connector.py | 2385 +++++++++++++++++ .../backend/service_desk_schemas.py | 371 +++ .../backend/service_desk_search.py | 337 +++ .../backend/service_desk_transport.py | 896 +++++++ tests/test_migrations.py | 5 +- tests/test_provider_state.py | 102 +- tests/test_service_desk_connector.py | 806 ++++++ tests/test_service_desk_transport.py | 286 ++ webui/package.json | 2 +- webui/src/api/externalServiceDesk.ts | 208 ++ .../src/features/ExternalServiceDeskPage.tsx | 573 ++++ webui/src/module.ts | 31 +- ...connector-governance-ui-structure.test.mjs | 16 + 22 files changed, 7348 insertions(+), 13 deletions(-) create mode 100644 docs/ZNUNY_OTRS_CONNECTOR.md create mode 100644 src/govoplan_connectors/backend/migrations/versions/c0f1a2b3c4d5_znuny_service_desk_connector.py create mode 100644 src/govoplan_connectors/backend/service_desk_connector.py create mode 100644 src/govoplan_connectors/backend/service_desk_schemas.py create mode 100644 src/govoplan_connectors/backend/service_desk_search.py create mode 100644 src/govoplan_connectors/backend/service_desk_transport.py create mode 100644 tests/test_service_desk_connector.py create mode 100644 tests/test_service_desk_transport.py create mode 100644 webui/src/api/externalServiceDesk.ts create mode 100644 webui/src/features/ExternalServiceDeskPage.tsx diff --git a/README.md b/README.md index 3bd7b02..9172945 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ every result. Migration into native Wiki is preview-only: conflicts, attachment collisions, unsupported macros, truncation, and source fingerprints are reported before a target-side write is considered. +The Znuny/OTRS-compatible service-desk adapter uses deployment-defined +GenericInterface REST routes. It supports identity-only links, bounded snapshot +imports, and ongoing synchronization with explicit authority, queue, ACL, and +dynamic-field mappings. Stable tickets, articles, and attachment references are +preserved with mapping-loss diagnostics; attachment bytes remain provider-side. +Optional Search integration rechecks the current profile, tenant, scope, and ACL +for every result. Revision-checked external updates are limited to governed-sync +profiles and use durable recovery evidence. Tickets, Helpdesk, and Cases remain +authoritative for their own business records and conversion workflows. + RSS and Atom emission is a bounded renderer, not an authority shortcut. Every selected entry declares whether it came from a GovOPlaN event, publication, case, or report and carries an opaque owning-module reference and optional @@ -106,3 +116,4 @@ See: - [OpenDesk integration map](docs/OPENDESK_INTEGRATION_MAP.md) - [Governed connector configuration](docs/GOVERNED_CONNECTOR_CONFIGURATION.md) - [MediaWiki and BlueSpice connector](docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md) +- [Znuny and OTRS-compatible service-desk connector](docs/ZNUNY_OTRS_CONNECTOR.md) diff --git a/docs/ZNUNY_OTRS_CONNECTOR.md b/docs/ZNUNY_OTRS_CONNECTOR.md new file mode 100644 index 0000000..ff6e2f5 --- /dev/null +++ b/docs/ZNUNY_OTRS_CONNECTOR.md @@ -0,0 +1,188 @@ +# Znuny and OTRS-compatible service-desk connector + +## Boundary + +Connectors owns the governed endpoint profile, GenericInterface REST transport, +credential hand-off, provider discovery, bounded synchronization, external +references, mapping diagnostics, health, and recovery evidence. It does not own +ticket, helpdesk, or case semantics. A synchronized provider ticket remains an +external service-desk ticket; creating or relating a GovOPlaN Ticket, Helpdesk +item, or Case is the responsibility of the corresponding optional module. + +The provider interface is `connectors.external_service_desk@1.0.0`, and the +target-tested provider declaration is `connectors.znuny.tickets`. + +## Governed configuration and routes + +Create an active connector definition/configuration with: + +- `provider`: `znuny`, `otrs`, or `znuny_otrs`; +- `protocol`: `generic_interface_rest` (the aliases `rest` and + `otrs_generic_interface_rest` are accepted); +- an HTTP(S) endpoint that passes the central outbound-request policy; and +- an optional scoped Core credential-envelope reference. + +Znuny GenericInterface routes are configured by each deployment rather than +being one universal product API. The profile therefore governs relative search, +ticket-read, and optional update paths and their supported +methods. Ticket and update paths must contain `{ticket_id}`. An optional +absolute HTTP(S) browser URL template may contain `{ticket_id}` or +`{ticket_number}`. `search_filters` carries up to 100 deployment-supported, +secret-free GenericTicket search criteria such as queue identifiers. It cannot +override synchronization bounds, ordering, change cursors, or authentication. +Routes cannot change authority or contain credentials. + +Header authentication is the recommended default. The adapter supports the +documented `X-OTRS-Header-UserLogin`, `X-OTRS-Header-Password`, +`X-OTRS-Header-SessionID`, and customer-login headers. A legacy credential may +declare `auth_mode: body`, but then every affected route must use POST. Secret +fields are never placed in a GET URL, persisted projection, diagnostic, or API +response. Core also treats these provider headers as redirect-sensitive and +removes them before following any cross-origin redirect. + +Reference configuration examples: + +- [Znuny GenericTicketConnectorREST example](https://doc.znuny.org/znuny/admin/webservices/examples/GenericTicketConnectorREST/index.html) +- [Znuny provider and header authentication](https://doc.znuny.org/znuny-7_1/admin/webservices/provider/) +- [Znuny web-service configuration](https://doc.znuny.org/znuny-7_3/admin/webservices/config/index.html) + +## Profile policy + +The integration and authority choices are intentionally separate but bounded: + +| Integration mode | Allowed authority | Maturity | +| --- | --- | --- | +| `link` | `linked_reference` | `link` through `search` | +| `import` | `external_authoritative`, `external_mirror` | exactly `read` | +| `synchronize` | `external_authoritative`, `governed_sync` | exactly `synchronize` | + +`link` retains stable identity and routing facts and asks the provider not to +return articles, attachments, or dynamic fields; any unexpectedly returned +content is still discarded. `import` is a deliberate bounded full snapshot and +never silently changes to delta synchronization. +`synchronize` completes a full reconciliation and then advances to overlap-safe +change-time deltas. + +Queue mappings govern inclusion, an optional opaque target queue reference, and +tenant or restricted visibility with ACL tokens. A restricted default requires +at least one ACL token. Provider-supplied `GovOPlaNVisibility` and +`GovOPlaNACL` fields win when valid. Otherwise a reviewed queue mapping wins, +then the profile default. Standard GenericInterface installations do not expose +a portable ticket-ACL contract, so every fallback is visible as a diagnostic. + +Dynamic-field mappings govern source name, optional target name, inclusion, and +`string`, `number`, `boolean`, `date`, or `json` conversion. Conversion loss, +unreturned configured fields, provider-specific ticket fields, synthesized +identities, and truncation are structured diagnostics rather than silent loss. + +## Discovery and synchronization + +Discovery performs a bounded ticket search and records health, API family, +route hash, the exact governed configuration revision/hash, product/version +evidence, capabilities, maturity, and diagnostics. A changed endpoint, +configuration revision, or route map invalidates that evidence: synchronization, +and updates fail closed until discovery is repeated, while prior projections are +invalidated and Search stays closed until a new full reconciliation verifies +them. Integration, route, queue, or dynamic-field mapping changes also reset the +cursor and require a full reconciliation. Recognized Znuny or OTRS major +versions 6 and later can reach synchronization maturity. An unverified +product/version stays at read maturity. An update route adds the technical +`publish` capability, but does not override profile authority. + +Full synchronization first obtains a stable ordered identity set, then reads at +most 500 tickets per call. A continued full run stores its offset, identity-set +fingerprint, and cumulative high-watermark. If the provider identity set changes +mid-run, the cursor is rejected and the operator must restart the full run. A +completed full run reconciles local removals and, for `synchronize` mode, +transitions to an overlap-safe delta cursor. + +An explicit `full` request always restarts at the beginning; `auto` continues a +committed full cursor or advances a completed delta cursor. A caller-supplied +cursor must exactly match the profile's committed cursor, and delta mode cannot +bootstrap a profile that has not completed its full synchronization. + +Delta synchronization reads the bounded candidate set, orders changes by +provider change time and ticket id, and suppresses only the exact ticket +revisions already observed at the current timestamp boundary. A previously seen +ticket that has changed again is therefore not lost. Every run requires a +profile-wide idempotency key. An exact replay returns the committed run without +contacting the provider; reuse for a different request or changed profile policy +is rejected. + +Operational bounds: + +- at most 10,000 ticket identities per profile search; +- at most 500 ticket reads per API call; +- at most 10 MB per provider response; +- a 20-second outbound timeout; and +- a 4,000-character cursor, including timestamp-boundary identities. + +Partition larger or unusually bursty providers into queue-scoped profiles by +combining provider-side queue `search_filters` with matching queue mappings. A +full-run fingerprint conflict requires a restart. A timestamp-boundary overflow +requires a narrower partition. These are explicit safety stops, not partial +success claims. + +## Mapping and attachment policy + +Each ticket projection retains stable ticket id/number, title, type, queue, +target queue reference, state, priority, owner, responsible user, customer user, +organization, service, SLA, creation/change times, mapped dynamic fields, +articles, attachment metadata, permission source, ACLs, canonical URL, content +hash, provider revision, cursor, observation time, and source provenance. + +Article and attachment references use Core `ExternalObjectReference` values. +Article bodies are capped at 200,000 characters. Attachment content is never +retained; only stable identity, filename, media type, size, disposition, content +id, article/ticket relationship, version, and provenance are mapped. Returned +bytes produce an `attachment_content_omitted` diagnostic. + +## Search authorization + +When Search is installed and both desired and discovered maturity permit it, +active non-deleted ticket projections are indexed. Search documents carry the +current visibility, ACL tokens, external reference, source revision, routing +metadata, article text, and bounded dynamic-field keywords. + +Authorization always fails closed unless all of these remain true: + +- the requesting principal belongs to the exact tenant; +- the principal has `connectors:service_desk:read`; +- the profile remains active and search-capable; +- the ticket remains active; and +- tenant visibility applies or a current account, membership, identity, group, + role, function, or scope ACL token intersects. + +Pausing a profile or changing fallback/queue ACLs updates or removes Search +projections immediately. Search also rechecks the current database state for +every result, so a delayed index update does not grant access. + +## Governed external updates and recovery + +An external update is allowed only when the profile is active, authority is +`governed_sync`, discovery recorded the `publish` capability, and the caller has +`connectors:service_desk:update`. The request must include the synchronized +provider revision and a new idempotency key. Supported governed fields are +title, queue, state, priority, owner, responsible user, and explicitly mapped +dynamic fields. + +Before dispatch, the adapter refetches the ticket and rejects a stale revision. +After dispatch, it refetches again and verifies both the changed revision and +every requested field value. A transport failure before a conclusive provider +response, an unchanged revision, or a requested value that cannot be confirmed +becomes `outcome_unknown`. Do not retry with another key. Inspect the provider +ticket and reconcile its accepted revision through the Core recovery evidence +first. Local database rollback cannot undo a remote provider mutation. + +## Administrator verification + +1. Create the governed definition/configuration and scoped credential envelope. +2. Create a restricted profile with reviewed routes, queue partitions, dynamic + fields, authority, and fallback ACLs. +3. Discover and confirm product/version, maturity, capabilities, and diagnostics. +4. Finish a keyed full run; continue while its cursor kind is `full`. +5. Run a new keyed automatic delta and inspect effects, losses, and health. +6. Verify one allowed and one denied Search principal against a restricted ticket. +7. If governed writes are enabled, update a non-production ticket with its + current revision, then verify provider and recovery evidence. +8. Reconcile every `outcome_unknown` run before any retry. diff --git a/pyproject.toml b/pyproject.toml index 0d3931e..beb26c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-connectors" -version = "0.1.21" +version = "0.1.22" description = "Governed connector catalogue and tabular source capabilities for GovOPlaN." readme = "README.md" requires-python = ">=3.12" @@ -12,7 +12,7 @@ license = "AGPL-3.0-or-later" authors = [{ name = "GovOPlaN" }] dependencies = [ "defusedxml>=0.7,<1", - "govoplan-core>=0.1.32", + "govoplan-core>=0.1.33", "openpyxl>=3.1.5,<4", ] diff --git a/src/govoplan_connectors/backend/db/models.py b/src/govoplan_connectors/backend/db/models.py index 61395f6..e5d04cb 100644 --- a/src/govoplan_connectors/backend/db/models.py +++ b/src/govoplan_connectors/backend/db/models.py @@ -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", diff --git a/src/govoplan_connectors/backend/dsar_provider.py b/src/govoplan_connectors/backend/dsar_provider.py index 1277839..6899c10 100644 --- a/src/govoplan_connectors/backend/dsar_provider.py +++ b/src/govoplan_connectors/backend/dsar_provider.py @@ -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", } diff --git a/src/govoplan_connectors/backend/manifest.py b/src/govoplan_connectors/backend/manifest.py index 5f54bc2..d956e32 100644 --- a/src/govoplan_connectors/backend/manifest.py +++ b/src/govoplan_connectors/backend/manifest.py @@ -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.", + }, + ), ), ) diff --git a/src/govoplan_connectors/backend/migrations/versions/c0f1a2b3c4d5_znuny_service_desk_connector.py b/src/govoplan_connectors/backend/migrations/versions/c0f1a2b3c4d5_znuny_service_desk_connector.py new file mode 100644 index 0000000..8f993d2 --- /dev/null +++ b/src/govoplan_connectors/backend/migrations/versions/c0f1a2b3c4d5_znuny_service_desk_connector.py @@ -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") diff --git a/src/govoplan_connectors/backend/provider_state.py b/src/govoplan_connectors/backend/provider_state.py index 4693a0d..f528f4c 100644 --- a/src/govoplan_connectors/backend/provider_state.py +++ b/src/govoplan_connectors/backend/provider_state.py @@ -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", ] diff --git a/src/govoplan_connectors/backend/router.py b/src/govoplan_connectors/backend/router.py index 7b7249a..7983b55 100644 --- a/src/govoplan_connectors/backend/router.py +++ b/src/govoplan_connectors/backend/router.py @@ -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, diff --git a/src/govoplan_connectors/backend/service_desk_connector.py b/src/govoplan_connectors/backend/service_desk_connector.py new file mode 100644 index 0000000..ac2e501 --- /dev/null +++ b/src/govoplan_connectors/backend/service_desk_connector.py @@ -0,0 +1,2385 @@ +from __future__ import annotations + +import hashlib +import json +from collections import Counter +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any +from urllib.parse import quote + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from govoplan_core.audit.logging import audit_event +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.external_references import ( + INTEGRATION_MATURITY_ORDER, + ExternalObjectReference, +) +from govoplan_core.core.runtime import get_registry +from govoplan_core.core.search import search_index_writer +from govoplan_core.security.credential_envelopes import ( + CredentialAccessContext, + CredentialEnvelopeError, + resolve_credential_envelope, +) +from govoplan_connectors.backend.db.models import ( + ConnectorConfiguration, + ConnectorDefinition, + ConnectorServiceDeskObject, + ConnectorServiceDeskProfile, + ConnectorServiceDeskSyncRun, +) +from govoplan_connectors.backend.recovery import ( + ConnectorRecoveryError, + begin_connector_external_mutation, +) +from govoplan_connectors.backend.service_desk_schemas import ( + ServiceDeskDiagnostic, + ServiceDeskDiscoveryResponse, + ServiceDeskDynamicFieldMapping, + ServiceDeskObjectItem, + ServiceDeskProfileCreateRequest, + ServiceDeskProfileItem, + ServiceDeskProfileUpdateRequest, + ServiceDeskQueueMapping, + ServiceDeskRouteMapping, + ServiceDeskSyncRequest, + ServiceDeskSyncRunItem, + ServiceDeskTicketUpdateRequest, + ServiceDeskTicketUpdateResponse, +) +from govoplan_connectors.backend.service_desk_transport import ( + HttpServiceDeskTransport, + ServiceDeskChangeBatch, + ServiceDeskTransport, + ServiceDeskTransportError, +) + + +SERVICE_DESK_READ_SCOPE = "connectors:service_desk:read" +SERVICE_DESK_ADMIN_SCOPE = "connectors:service_desk:admin" +SERVICE_DESK_SYNC_SCOPE = "connectors:service_desk:sync" +SERVICE_DESK_UPDATE_SCOPE = "connectors:service_desk:update" +SERVICE_DESK_CAPABILITY = "connectors.external_service_desk" +SERVICE_DESK_INTERFACE_VERSION = "1.0.0" +SERVICE_DESK_PROVIDER_ID = "connectors.znuny.tickets" +SERVICE_DESK_RESOURCE_TYPE = "external_service_desk_ticket" + +_KNOWN_TICKET_FIELDS = { + "TicketID", + "TicketNumber", + "Title", + "Type", + "TypeID", + "Queue", + "QueueID", + "State", + "StateID", + "StateType", + "Priority", + "PriorityID", + "Lock", + "LockID", + "Owner", + "OwnerID", + "Responsible", + "ResponsibleID", + "CustomerUserID", + "CustomerUser", + "CustomerID", + "Service", + "ServiceID", + "SLA", + "SLAID", + "Created", + "Changed", + "CreateTime", + "ChangeTime", + "Updated", + "CreateBy", + "ChangeBy", + "Age", + "ArchiveFlag", + "UntilTime", + "EscalationTime", + "EscalationUpdateTime", + "EscalationResponseTime", + "EscalationSolutionTime", + "Article", + "Articles", + "DynamicField", + "DynamicFields", + "Attachment", + "Attachments", + "GovOPlaNVisibility", + "GovOPlaNACL", +} + + +class ServiceDeskConnectorError(RuntimeError): + def __init__(self, code: str, message: str, *, retryable: bool = False) -> None: + super().__init__(message) + self.code = code + self.retryable = retryable + + +def create_profile( + session: Session, + principal: ApiPrincipal, + payload: ServiceDeskProfileCreateRequest, +) -> ServiceDeskProfileItem: + _require_scope(principal, SERVICE_DESK_ADMIN_SCOPE) + configuration = _configuration( + session, principal.tenant_id, payload.configuration_id, active=False + ) + _assert_service_desk_configuration(session, configuration) + item = ConnectorServiceDeskProfile( + tenant_id=principal.tenant_id, + configuration_id=configuration.id, + integration_mode=payload.integration_mode, + desired_maturity=payload.desired_maturity, + source_authority_mode=payload.source_authority_mode, + default_visibility=payload.default_visibility, + default_acl_tokens=list(payload.default_acl_tokens), + routes=payload.routes.model_dump(mode="json"), + queue_mappings=[value.model_dump(mode="json") for value in payload.queue_mappings], + dynamic_field_mappings=[ + value.model_dump(mode="json") for value in payload.dynamic_field_mappings + ], + updated_by=_actor_id(principal), + ) + session.add(item) + try: + session.flush() + except IntegrityError as exc: + raise ServiceDeskConnectorError( + "profile_conflict", + "This connector configuration already has a service-desk profile.", + ) from exc + _audit( + session, + principal, + action="profile.created", + object_type="connector_service_desk_profile", + object_id=item.id, + details={ + "configuration_id": configuration.id, + "integration_mode": item.integration_mode, + "desired_maturity": item.desired_maturity, + "source_authority_mode": item.source_authority_mode, + }, + ) + response = _profile_item(item, configuration) + session.commit() + return response + + +def update_profile( + session: Session, + principal: ApiPrincipal, + *, + profile_id: str, + payload: ServiceDeskProfileUpdateRequest, + registry: object | None = None, +) -> ServiceDeskProfileItem: + _require_scope(principal, SERVICE_DESK_ADMIN_SCOPE) + item = _profile(session, principal.tenant_id, profile_id) + if item.resource_revision != payload.expected_resource_revision: + raise ServiceDeskConnectorError( + "profile_conflict", "The profile changed; reload it before saving." + ) + integration_changed = ( + payload.integration_mode is not None + and payload.integration_mode != item.integration_mode + ) + routes_changed = ( + payload.routes is not None + and payload.routes.model_dump(mode="json") != dict(item.routes or {}) + ) + queue_mappings_changed = ( + payload.queue_mappings is not None + and [value.model_dump(mode="json") for value in payload.queue_mappings] + != list(item.queue_mappings or ()) + ) + dynamic_field_mappings_changed = ( + payload.dynamic_field_mappings is not None + and [ + value.model_dump(mode="json") + for value in payload.dynamic_field_mappings + ] + != list(item.dynamic_field_mappings or ()) + ) + for field_name in ( + "status", + "integration_mode", + "desired_maturity", + "source_authority_mode", + "default_visibility", + ): + value = getattr(payload, field_name) + if value is not None: + setattr(item, field_name, value) + if payload.default_acl_tokens is not None: + item.default_acl_tokens = list(payload.default_acl_tokens) + if payload.routes is not None: + item.routes = payload.routes.model_dump(mode="json") + if payload.queue_mappings is not None: + item.queue_mappings = [ + value.model_dump(mode="json") for value in payload.queue_mappings + ] + if payload.dynamic_field_mappings is not None: + item.dynamic_field_mappings = [ + value.model_dump(mode="json") for value in payload.dynamic_field_mappings + ] + _validate_profile_state(item) + requires_full_restart = any( + ( + integration_changed, + routes_changed, + queue_mappings_changed, + dynamic_field_mappings_changed, + ) + ) + rows = tuple( + session.scalars( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.tenant_id == principal.tenant_id, + ConnectorServiceDeskObject.profile_id == item.id, + ConnectorServiceDeskObject.object_type == "ticket", + ConnectorServiceDeskObject.status != "deleted", + ) + ) + ) + for row in rows: + data = dict(row.mapped_data or {}) + queue = ( + str(data.get("queue", {}).get("name") or "") + if isinstance(data.get("queue"), Mapping) + else "" + ) + queue_mapping = _queue_mapping(item, queue) + if queue_mapping is not None and not bool(queue_mapping.get("include", True)): + row.status = "deleted" + row.resource_revision += 1 + row.observed_at = _now() + row.content_hash = _hash( + { + "external_id": row.external_id, + "status": "deleted", + "source_revision": row.source_revision, + } + ) + continue + if data.get("permission_source") == "provider": + visibility = row.visibility + tokens = list(row.acl_tokens or ()) + source = "provider" + else: + visibility, tokens, source = _effective_acl(item, queue_mapping, raw={}) + row.visibility = visibility + row.acl_tokens = tokens + data["permission_source"] = source + data["target_queue_ref"] = queue_mapping.get("target_queue_ref") if queue_mapping else None + data["integration_mode"] = item.integration_mode + data["source_authority_mode"] = item.source_authority_mode + if item.integration_mode == "link": + data["articles"] = [] + data["attachments"] = [] + data["dynamic_fields"] = {} + _refresh_embedded_references(data, item) + row.mapped_data = data + row.provenance = { + **dict(row.provenance or {}), + "integration_mode": item.integration_mode, + "source_authority_mode": item.source_authority_mode, + } + row.content_hash = _content_hash(data, visibility=visibility, acl_tokens=tokens) + row.resource_revision += 1 + row.observed_at = _now() + if requires_full_restart: + item.last_sync_cursor = None + item.last_high_watermark = None + item.health_status = "unknown" + item.health_details = { + "code": "full_synchronization_required", + "summary": "Profile mappings changed; run a full synchronization before relying on freshness.", + } + if routes_changed: + invalidated = _invalidate_projection_rows(rows) + item.capabilities = [] + item.discovered_maturity = "discover" + item.discovery_revision = None + item.discovered_configuration_revision = None + item.discovered_configuration_hash = None + item.discovery_evidence = {} + item.discovered_at = None + item.health_status = "unknown" + item.health_details = { + "code": "rediscovery_required", + "summary": "GenericInterface routes changed; rediscover the provider before synchronizing or updating tickets.", + "invalidated_projections": invalidated, + } + item.resource_revision += 1 + item.updated_by = _actor_id(principal) + session.flush() + configuration = _configuration( + session, principal.tenant_id, item.configuration_id, active=False + ) + diagnostics: list[dict[str, Any]] = [] + _update_search( + session, + principal, + rows, + profile=item, + registry=registry or get_registry(), + diagnostics=diagnostics, + source_active=item.status == "active", + ) + if diagnostics: + item.health_details = { + **dict(item.health_details or {}), + "search_diagnostics": diagnostics, + } + _audit( + session, + principal, + action="profile.updated", + object_type="connector_service_desk_profile", + object_id=item.id, + details={ + "resource_revision": item.resource_revision, + "status": item.status, + "integration_mode": item.integration_mode, + "desired_maturity": item.desired_maturity, + }, + ) + response = _profile_item(item, configuration) + session.commit() + return response + + +def list_profiles( + session: Session, principal: ApiPrincipal +) -> tuple[ServiceDeskProfileItem, ...]: + _require_any_scope(principal, SERVICE_DESK_READ_SCOPE, SERVICE_DESK_ADMIN_SCOPE) + rows = tuple( + session.scalars( + select(ConnectorServiceDeskProfile) + .where(ConnectorServiceDeskProfile.tenant_id == principal.tenant_id) + .order_by( + ConnectorServiceDeskProfile.created_at, + ConnectorServiceDeskProfile.id, + ) + ) + ) + configurations = _configurations(session, principal.tenant_id, rows) + return tuple(_profile_item(row, configurations[row.configuration_id]) for row in rows) + + +def discover_profile( + session: Session, + principal: ApiPrincipal, + *, + profile_id: str, + transport: ServiceDeskTransport | None = None, +) -> ServiceDeskDiscoveryResponse: + _require_any_scope(principal, SERVICE_DESK_ADMIN_SCOPE, SERVICE_DESK_SYNC_SCOPE) + item = _profile(session, principal.tenant_id, profile_id) + configuration = _configuration( + session, principal.tenant_id, item.configuration_id, active=True + ) + _assert_service_desk_configuration(session, configuration) + previous_discovery_revision = item.discovery_revision + previous_configuration_revision = item.discovered_configuration_revision + previous_configuration_hash = item.discovered_configuration_hash + try: + discovery = (transport or HttpServiceDeskTransport()).discover( + endpoint_url=_endpoint(configuration), + credential=_credential(session, principal, configuration), + routes=dict(item.routes or {}), + ) + except ServiceDeskTransportError as exc: + item.health_status = "unavailable" + item.health_details = { + "code": exc.code, + "retryable": exc.retryable, + "summary": str(exc), + "checked_at": _now().isoformat(), + } + session.commit() + raise ServiceDeskConnectorError( + "discovery_unavailable", str(exc), retryable=exc.retryable + ) from exc + discovery_revision = str(discovery["revision"]) + invalidate_projection = item.discovered_at is not None and ( + previous_configuration_revision != configuration.resource_revision + or previous_configuration_hash != configuration.effective_hash + or previous_discovery_revision != discovery_revision + ) + invalidated = 0 + if invalidate_projection: + rows = tuple( + session.scalars( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.tenant_id == principal.tenant_id, + ConnectorServiceDeskObject.profile_id == item.id, + ConnectorServiceDeskObject.object_type == "ticket", + ConnectorServiceDeskObject.status != "deleted", + ) + ) + ) + invalidated = _invalidate_projection_rows(rows) + item.last_sync_cursor = None + item.last_high_watermark = None + item.product = str(discovery["product"]) + item.product_version = _optional_text(discovery.get("product_version")) + item.capabilities = [str(value) for value in discovery.get("capabilities") or ()] + item.discovered_maturity = str(discovery["maturity"]) + item.discovery_revision = discovery_revision + item.discovered_configuration_revision = configuration.resource_revision + item.discovered_configuration_hash = configuration.effective_hash + item.discovery_evidence = { + "api_family": discovery.get("api_family"), + "transport": dict(discovery.get("evidence") or {}), + "capabilities": list(item.capabilities), + "routes_hash": _hash(item.routes), + "configuration_revision": configuration.resource_revision, + "configuration_hash": configuration.effective_hash, + } + item.health_status = str(discovery["health_status"]) + item.health_details = { + "diagnostics": list(discovery.get("diagnostics") or ()), + "checked_at": _now().isoformat(), + "projection_invalidated": invalidate_projection, + "invalidated_projections": invalidated, + "full_synchronization_required": invalidate_projection, + } + item.discovered_at = _now() + item.resource_revision += 1 + session.flush() + _audit( + session, + principal, + action="profile.discovered", + object_type="connector_service_desk_profile", + object_id=item.id, + details={ + "product": item.product, + "product_version": item.product_version, + "maturity": item.discovered_maturity, + "health": item.health_status, + "discovery_revision": item.discovery_revision, + "projection_invalidated": invalidate_projection, + "invalidated_projections": invalidated, + }, + ) + response = ServiceDeskDiscoveryResponse( + profile=_profile_item(item, configuration), + product=item.product, + product_version=item.product_version, + api_family=str(discovery.get("api_family") or "generic_interface_rest"), + capabilities=list(item.capabilities), + maturity=item.discovered_maturity, + health_status=item.health_status, + diagnostics=[ + ServiceDeskDiagnostic.model_validate(value) + for value in discovery.get("diagnostics") or () + ], + revision=item.discovery_revision, + ) + session.commit() + return response + + +def synchronize_profile( + session: Session, + principal: ApiPrincipal, + *, + profile_id: str, + payload: ServiceDeskSyncRequest, + transport: ServiceDeskTransport | None = None, + registry: object | None = None, +) -> ServiceDeskSyncRunItem: + _require_scope(principal, SERVICE_DESK_SYNC_SCOPE) + profile = _profile(session, principal.tenant_id, profile_id) + if profile.status != "active": + raise ServiceDeskConnectorError("profile_paused", "The service-desk profile is paused.") + configuration = _configuration( + session, principal.tenant_id, profile.configuration_id, active=True + ) + _assert_sync_maturity(profile, configuration) + if ( + payload.cursor is not None + and payload.cursor != profile.last_sync_cursor + ): + raise ServiceDeskConnectorError( + "cursor_conflict", + "The requested cursor is not the profile's current committed cursor; reload before synchronizing.", + ) + mode = _sync_mode(profile, payload) + cursor = ( + payload.cursor + if payload.cursor is not None + else None + if payload.mode == "full" + else profile.last_sync_cursor + ) + request_hash = _hash( + { + "profile_id": profile.id, + "profile_policy": _profile_policy_hash(profile), + "requested_mode": payload.mode, + "requested_cursor": payload.cursor, + "limit": payload.limit, + } + ) + replay = _run_replay( + session, + tenant_id=principal.tenant_id, + profile_id=profile.id, + idempotency_key=payload.idempotency_key, + request_hash=request_hash, + ) + if replay is not None: + return _run_item(replay) + run = ConnectorServiceDeskSyncRun( + tenant_id=principal.tenant_id, + profile_id=profile.id, + mode=mode, + idempotency_key=payload.idempotency_key, + request_hash=request_hash, + status="running", + cursor_before=cursor, + counts={}, + effects=[], + diagnostics=[], + provenance={ + "profile_revision": profile.resource_revision, + "configuration_revision": configuration.resource_revision, + "configuration_hash": configuration.effective_hash, + "integration_mode": profile.integration_mode, + "source_authority_mode": profile.source_authority_mode, + }, + created_by=_actor_id(principal), + started_at=_now(), + ) + session.add(run) + session.flush() + try: + transport_routes = dict(profile.routes or {}) + if profile.integration_mode == "link": + transport_routes["_identity_only"] = True + batch = (transport or HttpServiceDeskTransport()).changes( + endpoint_url=_endpoint(configuration), + credential=_credential(session, principal, configuration), + routes=transport_routes, + cursor=cursor, + limit=payload.limit, + force_full=mode == "full", + ) + except ServiceDeskTransportError as exc: + run.status = "failed" + run.diagnostics = [ + _diagnostic("error", exc.code, str(exc), retryable=exc.retryable) + ] + run.finished_at = _now() + profile.health_status = "degraded" if exc.retryable else "misconfigured" + profile.health_details = { + "code": exc.code, + "retryable": exc.retryable, + "last_sync_attempt": run.finished_at.isoformat(), + } + session.commit() + raise ServiceDeskConnectorError( + "synchronization_failed", str(exc), retryable=exc.retryable + ) from exc + + try: + with session.begin_nested(): + effects, diagnostics, changed_rows = _apply_change_batch( + session, + profile, + batch=batch, + mode=mode, + ) + session.flush() + except ServiceDeskConnectorError as exc: + run.status = "failed" + run.counts = {} + run.effects = [] + run.diagnostics = [_diagnostic("error", exc.code, str(exc))] + run.finished_at = _now() + profile.health_status = "misconfigured" + profile.health_details = { + "code": exc.code, + "summary": str(exc), + "last_sync_attempt": run.finished_at.isoformat(), + } + session.commit() + raise ServiceDeskConnectorError("synchronization_failed", str(exc)) from exc + _update_search( + session, + principal, + changed_rows, + profile=profile, + registry=registry or get_registry(), + diagnostics=diagnostics, + ) + counts = dict(Counter(str(value["effect"]) for value in effects)) + effective_cursor = _effective_sync_cursor( + profile, + mode=mode, + complete=batch.complete, + next_cursor=batch.next_cursor, + high_watermark=batch.high_watermark, + ) + run.status = "completed" + run.cursor_after = effective_cursor + run.high_watermark = batch.high_watermark + run.counts = counts + run.effects = effects + run.diagnostics = diagnostics + run.provenance = {**dict(run.provenance or {}), "transport": dict(batch.evidence)} + run.finished_at = _now() + profile.last_sync_cursor = effective_cursor + profile.last_high_watermark = batch.high_watermark + profile.health_status = "healthy" + profile.health_details = { + "last_sync_at": run.finished_at.isoformat(), + "complete": batch.complete, + "counts": counts, + } + profile.resource_revision += 1 + session.flush() + _audit( + session, + principal, + action="profile.synchronized", + object_type="connector_service_desk_sync_run", + object_id=run.id, + details={ + "profile_id": profile.id, + "mode": mode, + "counts": counts, + "complete": batch.complete, + "high_watermark": batch.high_watermark, + }, + ) + response = _run_item(run) + session.commit() + return response + + +def list_objects( + session: Session, + principal: ApiPrincipal, + *, + profile_id: str, + cursor: str | None = None, + limit: int = 100, +) -> tuple[tuple[ServiceDeskObjectItem, ...], str | None]: + _require_any_scope(principal, SERVICE_DESK_READ_SCOPE, SERVICE_DESK_ADMIN_SCOPE) + profile = _profile(session, principal.tenant_id, profile_id) + statement = select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.tenant_id == principal.tenant_id, + ConnectorServiceDeskObject.profile_id == profile.id, + ) + if cursor: + statement = statement.where(ConnectorServiceDeskObject.id > cursor) + rows = tuple( + session.scalars( + statement.order_by(ConnectorServiceDeskObject.id.asc()).limit(limit + 1) + ) + ) + selected = rows[:limit] + next_cursor = selected[-1].id if len(rows) > limit and selected else None + return tuple(_object_item(row) for row in selected), next_cursor + + +def _apply_change_batch( + session: Session, + profile: ConnectorServiceDeskProfile, + *, + batch: ServiceDeskChangeBatch, + mode: str, +) -> tuple[ + list[dict[str, Any]], + list[dict[str, Any]], + list[ConnectorServiceDeskObject], +]: + effects: list[dict[str, Any]] = [] + diagnostics: list[dict[str, Any]] = [] + changed_rows: list[ConnectorServiceDeskObject] = [] + for raw in batch.changes: + if profile.integration_mode == "synchronize" and not _optional_text( + raw.get("Changed") or raw.get("ChangeTime") or raw.get("Updated") + ): + raise ServiceDeskConnectorError( + "change_timestamp_missing", + "Continuous synchronization requires every provider ticket to expose a stable change timestamp.", + ) + mapped, mapped_diagnostics = _map_ticket(profile, raw) + diagnostics.extend(mapped_diagnostics) + if mapped is None: + excluded = _exclude_object( + session, + profile, + external_id=_external_id(raw), + source_revision=_source_revision(raw), + ) + if excluded is not None: + changed_rows.append(excluded) + effects.append( + { + "effect": "deleted" if excluded is not None else "ignored", + "external_id": _external_id(raw), + "title": _optional_text(raw.get("Title")) or "Excluded ticket", + "reason_code": "queue_excluded", + } + ) + continue + row, effect = _upsert_object( + session, + profile, + mapped, + cursor=batch.next_cursor or batch.high_watermark, + ) + effects.append(effect) + if effect["effect"] != "unchanged" or mode == "full": + changed_rows.append(row) + if mode == "full" and batch.complete and batch.live_ids is not None: + deleted = _reconcile_deleted(session, profile, set(batch.live_ids)) + changed_rows.extend(deleted) + effects.extend( + { + "effect": "deleted", + "external_id": row.external_id, + "title": row.title, + "source_revision": row.source_revision, + } + for row in deleted + ) + return effects, diagnostics, changed_rows + + +def list_runs( + session: Session, + principal: ApiPrincipal, + *, + profile_id: str | None = None, + limit: int = 100, +) -> tuple[ServiceDeskSyncRunItem, ...]: + _require_any_scope(principal, SERVICE_DESK_READ_SCOPE, SERVICE_DESK_ADMIN_SCOPE) + statement = select(ConnectorServiceDeskSyncRun).where( + ConnectorServiceDeskSyncRun.tenant_id == principal.tenant_id + ) + if profile_id: + _profile(session, principal.tenant_id, profile_id) + statement = statement.where(ConnectorServiceDeskSyncRun.profile_id == profile_id) + return tuple( + _run_item(row) + for row in session.scalars( + statement.order_by( + ConnectorServiceDeskSyncRun.started_at.desc(), + ConnectorServiceDeskSyncRun.id.desc(), + ).limit(limit) + ) + ) + + +def update_ticket( + session: Session, + principal: ApiPrincipal, + *, + profile_id: str, + external_ticket_id: str, + payload: ServiceDeskTicketUpdateRequest, + transport: ServiceDeskTransport | None = None, + registry: object | None = None, + durable_recovery: bool = True, +) -> ServiceDeskTicketUpdateResponse: + _require_scope(principal, SERVICE_DESK_UPDATE_SCOPE) + profile = _profile(session, principal.tenant_id, profile_id) + if profile.status != "active": + raise ServiceDeskConnectorError("profile_paused", "The service-desk profile is paused.") + if profile.source_authority_mode != "governed_sync": + raise ServiceDeskConnectorError( + "external_authoritative", + "Ticket updates require the governed-sync source-of-truth mode.", + ) + if ( + profile.discovered_at is None + or "publish" not in profile.capabilities + or not _supports(profile.desired_maturity, "publish") + ): + raise ServiceDeskConnectorError( + "update_unsupported", + "The configured and discovered connector maturity does not support updates.", + ) + row = _object_by_external_id(session, profile.id, external_ticket_id) + if row is None or row.status == "deleted": + raise ServiceDeskConnectorError("ticket_not_found", "Synchronized external ticket not found.") + changes = _update_payload(profile, payload) + request_hash = _hash( + { + "profile_id": profile.id, + "external_ticket_id": external_ticket_id, + "expected_external_revision": payload.expected_external_revision, + "changes": changes, + } + ) + replay = _run_replay( + session, + tenant_id=principal.tenant_id, + profile_id=profile.id, + idempotency_key=payload.idempotency_key, + request_hash=request_hash, + ) + if replay is not None: + return ServiceDeskTicketUpdateResponse( + run=_run_item(replay), + object=_object_item(row), + accepted=replay.status == "completed", + outcome_unknown=replay.status == "outcome_unknown", + ) + if row.source_revision != payload.expected_external_revision: + raise ServiceDeskConnectorError( + "external_revision_conflict", + "The synchronized ticket changed; reload it before updating.", + ) + configuration = _configuration( + session, principal.tenant_id, profile.configuration_id, active=True + ) + _assert_discovery_configuration(profile, configuration) + run = ConnectorServiceDeskSyncRun( + tenant_id=principal.tenant_id, + profile_id=profile.id, + mode="update", + idempotency_key=payload.idempotency_key, + request_hash=request_hash, + status="prepared", + cursor_before=profile.last_sync_cursor, + counts={}, + effects=[], + diagnostics=[], + provenance={ + "configuration_revision": configuration.resource_revision, + "configuration_hash": configuration.effective_hash, + "expected_external_revision": payload.expected_external_revision, + "changed_fields": sorted(changes), + }, + created_by=_actor_id(principal), + started_at=_now(), + ) + session.add(run) + session.flush() + recovery = None + if durable_recovery: + try: + recovery = begin_connector_external_mutation( + session, + tenant_id=principal.tenant_id, + provider_id=f"znuny-otrs:{profile.id}", + idempotency_key=payload.idempotency_key, + request_sha256=request_hash, + source_revision=payload.expected_external_revision, + cursor=profile.last_sync_cursor, + dry_run_evidence={ + "profile_revision": profile.resource_revision, + "configuration_revision": configuration.resource_revision, + "changed_fields": sorted(changes), + }, + resource_type=SERVICE_DESK_RESOURCE_TYPE, + resource_id=external_ticket_id, + ) + except ConnectorRecoveryError as exc: + session.rollback() + raise ServiceDeskConnectorError("recovery_unavailable", str(exc)) from exc + try: + result = (transport or HttpServiceDeskTransport()).update_ticket( + endpoint_url=_endpoint(configuration), + credential=_credential(session, principal, configuration), + routes=dict(profile.routes or {}), + ticket_id=external_ticket_id, + expected_revision=payload.expected_external_revision, + changes=changes, + ) + except ServiceDeskTransportError as exc: + run.status = "outcome_unknown" if exc.outcome_unknown else "rejected" + run.diagnostics = [ + _diagnostic("error", exc.code, str(exc), retryable=exc.retryable) + ] + run.finished_at = _now() + session.commit() + if recovery is not None: + if exc.outcome_unknown: + recovery.outcome_unknown(summary=str(exc), provider_code=exc.code) + else: + recovery.reject(summary=str(exc), provider_code=exc.code) + if exc.outcome_unknown: + raise ServiceDeskConnectorError( + "update_outcome_unknown", + "The ticket update outcome is unknown; inspect the provider before retrying.", + ) from exc + raise ServiceDeskConnectorError("update_rejected", str(exc)) from exc + mapped, diagnostics = _map_ticket(profile, result.ticket) + if mapped is None: + row.status = "deleted" + row.source_revision = result.revision + row.observed_at = _now() + row.resource_revision += 1 + row.content_hash = _hash( + { + "external_id": row.external_id, + "status": "deleted", + "source_revision": row.source_revision, + } + ) + effect = { + "effect": "deleted", + "external_id": row.external_id, + "title": row.title, + "source_revision": row.source_revision, + "status": row.status, + "reason_code": "queue_excluded", + } + diagnostics.append( + _diagnostic( + "info", + "updated_ticket_excluded", + "The accepted update moved the ticket into an excluded queue; the local projection was removed.", + object_ref=row.external_id, + ) + ) + else: + row, effect = _upsert_object( + session, profile, mapped, cursor=result.revision + ) + run.status = "completed" + run.counts = {effect["effect"]: 1} + run.effects = [effect] + run.diagnostics = diagnostics + run.provenance = {**dict(run.provenance or {}), "provider": dict(result.evidence)} + run.finished_at = _now() + session.flush() + _update_search( + session, + principal, + [row], + profile=profile, + registry=registry or get_registry(), + diagnostics=diagnostics, + ) + _audit( + session, + principal, + action="ticket.updated", + object_type=SERVICE_DESK_RESOURCE_TYPE, + object_id=row.id, + details={ + "profile_id": profile.id, + "external_ticket_id": row.external_id, + "accepted_revision": row.source_revision, + "changed_fields": sorted(changes), + "recovery_operation_id": recovery.operation_id if recovery else None, + }, + ) + response = ServiceDeskTicketUpdateResponse( + run=_run_item(run), + object=_object_item(row), + accepted=True, + outcome_unknown=False, + ) + if recovery is not None: + recovery.commit_success( + session, + provider_evidence={ + "verified": True, + "checks": { + "provider_result": "Success", + "external_ticket_id": row.external_id, + "accepted_revision": row.source_revision, + }, + }, + ) + else: + session.commit() + return response + + +class ExternalServiceDeskCapability: + def list_references( + self, + session: object, + principal: object, + *, + profile_id: str, + limit: int = 100, + ) -> Sequence[ExternalObjectReference]: + if not isinstance(session, Session) or not isinstance(principal, ApiPrincipal): + raise TypeError("External service-desk references require platform context.") + rows, _cursor = list_objects( + session, principal, profile_id=profile_id, limit=min(limit, 500) + ) + profile = _profile(session, principal.tenant_id, profile_id) + return tuple( + 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, + observed_at=row.observed_at, + metadata={ + "ticket_number": row.external_ticket_number, + "title": row.title, + "status": row.status, + }, + ) + for row in rows + ) + + +def _map_ticket( + profile: ConnectorServiceDeskProfile, + raw: Mapping[str, Any], +) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + external_id = _external_id(raw) + ticket_number = _optional_text(raw.get("TicketNumber")) + title = _optional_text(raw.get("Title")) or f"External ticket {ticket_number or external_id}" + queue = _optional_text(raw.get("Queue")) or _optional_text(raw.get("QueueID")) or "unmapped" + queue_mapping = _queue_mapping(profile, queue) + diagnostics: list[dict[str, Any]] = [] + if queue_mapping is not None and not bool(queue_mapping.get("include", True)): + return None, [ + _diagnostic( + "info", + "queue_excluded", + "The ticket queue is explicitly excluded from this profile.", + object_ref=external_id, + field="Queue", + ) + ] + if queue_mapping is None: + diagnostics.append( + _diagnostic( + "warning", + "queue_unmapped", + "No target queue mapping exists; the configured default ACL was applied.", + object_ref=external_id, + field="Queue", + details={"source_queue": queue}, + ) + ) + visibility, acl_tokens, permission_source = _effective_acl( + profile, queue_mapping, raw=raw + ) + if permission_source != "provider": + diagnostics.append( + _diagnostic( + "warning", + "permission_mapping_defaulted", + "GenericInterface exposes no standard ticket ACL; the governed queue/default ACL was applied.", + object_ref=external_id, + field="permissions", + ) + ) + observed_at = _now() + source_revision = _source_revision(raw) + link_only = profile.integration_mode == "link" + dynamic_fields, dynamic_diagnostics = _map_dynamic_fields( + profile, raw, external_id=external_id + ) + diagnostics.extend(dynamic_diagnostics) + articles, attachments, article_diagnostics = _map_articles( + profile, + raw, + external_id=external_id, + observed_at=observed_at, + include_content=not link_only, + ) + diagnostics.extend(article_diagnostics) + if link_only: + if articles or attachments or dynamic_fields: + diagnostics.append( + _diagnostic( + "info", + "link_mode_content_omitted", + "Link mode retains identity and routing facts but does not import articles, attachments, or dynamic-field values.", + object_ref=external_id, + ) + ) + articles = [] + attachments = [] + dynamic_fields = {} + unsupported = sorted(str(key) for key in raw if str(key) not in _KNOWN_TICKET_FIELDS) + if unsupported: + diagnostics.append( + _diagnostic( + "warning", + "unsupported_ticket_fields", + "Provider-specific ticket fields were retained only as loss diagnostics.", + object_ref=external_id, + details={"fields": unsupported[:100], "truncated": len(unsupported) > 100}, + ) + ) + canonical_url = _canonical_url(profile, external_id, ticket_number) + mapped_data: dict[str, Any] = { + "ticket_id": external_id, + "ticket_number": ticket_number, + "title": title, + "type": _reference_fact(raw, "Type", "TypeID"), + "queue": {"name": queue, "external_id": _optional_text(raw.get("QueueID"))}, + "target_queue_ref": queue_mapping.get("target_queue_ref") if queue_mapping else None, + "state": _reference_fact(raw, "State", "StateID", extra={"type": raw.get("StateType")}), + "priority": _reference_fact(raw, "Priority", "PriorityID"), + "owner": _principal_fact(raw, "Owner", "OwnerID"), + "responsible": _principal_fact(raw, "Responsible", "ResponsibleID"), + "customer_user": _principal_fact(raw, "CustomerUser", "CustomerUserID"), + "organization": { + "external_id": _optional_text(raw.get("CustomerID")), + "name": _optional_text(raw.get("CustomerID")), + }, + "service": _reference_fact(raw, "Service", "ServiceID"), + "sla": _reference_fact(raw, "SLA", "SLAID"), + "created_at": _optional_text(raw.get("Created") or raw.get("CreateTime")), + "changed_at": source_revision, + "articles": articles, + "attachments": attachments, + "dynamic_fields": dynamic_fields, + "permission_source": permission_source, + "integration_mode": profile.integration_mode, + "source_authority_mode": profile.source_authority_mode, + "unsupported_fields": unsupported[:100], + } + return { + "external_id": external_id, + "external_ticket_number": ticket_number, + "title": title, + "canonical_url": canonical_url, + "status": "active", + "source_revision": source_revision, + "visibility": visibility, + "acl_tokens": acl_tokens, + "mapped_data": mapped_data, + "content_hash": _content_hash( + mapped_data, visibility=visibility, acl_tokens=acl_tokens + ), + "provenance": { + "connector_profile_id": profile.id, + "source_system": profile.product, + "source_version": profile.product_version, + "source_revision": source_revision, + "observed_at": observed_at.isoformat(), + "integration_mode": profile.integration_mode, + "source_authority_mode": profile.source_authority_mode, + "credential_material_retained": False, + "attachment_content_retained": False, + }, + "source_updated_at": _parse_datetime(source_revision), + "observed_at": observed_at, + }, diagnostics + + +def _map_articles( + profile: ConnectorServiceDeskProfile, + raw: Mapping[str, Any], + *, + external_id: str, + observed_at: datetime, + include_content: bool, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + values = raw.get("Article") if raw.get("Article") is not None else raw.get("Articles") + articles_raw = _mapping_list(values) + top_attachments = _mapping_list( + raw.get("Attachment") if raw.get("Attachment") is not None else raw.get("Attachments") + ) + diagnostics: list[dict[str, Any]] = [] + articles: list[dict[str, Any]] = [] + attachments: list[dict[str, Any]] = [] + for index, article in enumerate(articles_raw): + article_id = ( + _optional_text(article.get("ArticleID")) + or _optional_text(article.get("ID")) + or _hash({"ticket": external_id, "index": index, "article": article})[:32] + ) + if not article.get("ArticleID") and not article.get("ID"): + diagnostics.append( + _diagnostic( + "warning", + "article_identity_synthesized", + "An article had no stable provider identity; a snapshot-local identity was synthesized.", + object_ref=external_id, + field="Article.ArticleID", + ) + ) + body = str(article.get("Body") or "") + if len(body) > 200_000: + diagnostics.append( + _diagnostic( + "warning", + "article_body_truncated", + "The article body exceeded the 200000-character mapping limit.", + object_ref=article_id, + field="Article.Body", + ) + ) + article_attachments = _mapping_list( + article.get("Attachment") + if article.get("Attachment") is not None + else article.get("Attachments") + ) + mapped_attachments = [ + _map_attachment( + profile, + value, + ticket_id=external_id, + article_id=article_id, + observed_at=observed_at, + diagnostics=diagnostics, + ) + for value in article_attachments + ] + attachments.extend(mapped_attachments) + articles.append( + { + "external_id": article_id, + "subject": _optional_text(article.get("Subject")), + "body": body[:200_000] if include_content else None, + "content_type": _optional_text(article.get("ContentType")), + "communication_channel": _optional_text( + article.get("CommunicationChannel") or article.get("ArticleType") + ), + "sender_type": _optional_text(article.get("SenderType")), + "from": _optional_text(article.get("From")), + "to": _optional_text(article.get("To")), + "created_at": _optional_text(article.get("Created") or article.get("CreateTime")), + "created_by": _optional_text(article.get("CreateBy")), + "visible_for_customer": _boolean(article.get("VisibleForCustomer")), + "attachment_refs": [value["reference"] for value in mapped_attachments], + "reference": _reference( + profile, + object_type="article", + object_id=article_id, + version=_optional_text(article.get("Changed") or article.get("Created")), + observed_at=observed_at, + metadata={"ticket_id": external_id}, + ), + } + ) + for attachment in top_attachments: + attachments.append( + _map_attachment( + profile, + attachment, + ticket_id=external_id, + article_id=None, + observed_at=observed_at, + diagnostics=diagnostics, + ) + ) + return articles, attachments, diagnostics + + +def _map_attachment( + profile: ConnectorServiceDeskProfile, + raw: Mapping[str, Any], + *, + ticket_id: str, + article_id: str | None, + observed_at: datetime, + diagnostics: list[dict[str, Any]], +) -> dict[str, Any]: + filename = _optional_text(raw.get("Filename") or raw.get("Name")) or "attachment" + attachment_id = ( + _optional_text(raw.get("AttachmentID")) + or _optional_text(raw.get("FileID")) + or _hash( + { + "ticket": ticket_id, + "article": article_id, + "filename": filename, + "size": raw.get("Filesize") or raw.get("Size"), + } + )[:32] + ) + if raw.get("Content") or raw.get("ContentBase64"): + diagnostics.append( + _diagnostic( + "warning", + "attachment_content_omitted", + "Attachment bytes are not retained by the connector; only stable metadata and provenance are mapped.", + object_ref=attachment_id, + field="Attachment.Content", + ) + ) + return { + "external_id": attachment_id, + "article_id": article_id, + "name": filename, + "content_type": _optional_text(raw.get("ContentType")), + "size": _integer(raw.get("Filesize") or raw.get("Size")), + "content_id": _optional_text(raw.get("ContentID")), + "disposition": _optional_text(raw.get("Disposition")), + "content_retained": False, + "reference": _reference( + profile, + object_type="attachment", + object_id=attachment_id, + version=_optional_text(raw.get("Changed")), + observed_at=observed_at, + metadata={ + "ticket_id": ticket_id, + "article_id": article_id, + "filename": filename, + }, + ), + } + + +def _map_dynamic_fields( + profile: ConnectorServiceDeskProfile, + raw: Mapping[str, Any], + *, + external_id: str, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + values = raw.get("DynamicField") if raw.get("DynamicField") is not None else raw.get("DynamicFields") + source: dict[str, Any] = {} + if isinstance(values, Mapping): + source = {str(key): value for key, value in values.items()} + else: + for item in _mapping_list(values): + name = _optional_text(item.get("Name")) + if name: + source[name] = item.get("Value") + mappings = { + str(value.get("source_name")): value + for value in profile.dynamic_field_mappings or () + } + result: dict[str, Any] = {} + diagnostics: list[dict[str, Any]] = [] + for name, value in source.items(): + mapping = mappings.get(name) + if mapping is not None and not bool(mapping.get("include", True)): + diagnostics.append( + _diagnostic( + "info", + "dynamic_field_excluded", + "A configured dynamic field was deliberately excluded.", + object_ref=external_id, + field=f"DynamicField.{name}", + ) + ) + continue + target = str(mapping.get("target_name") or name) if mapping else name + value_type = str(mapping.get("value_type") or "json") if mapping else "json" + try: + result[target] = _coerce_dynamic(value, value_type) + except (TypeError, ValueError): + diagnostics.append( + _diagnostic( + "warning", + "dynamic_field_type_loss", + "A dynamic field could not be converted to its governed target type and was omitted.", + object_ref=external_id, + field=f"DynamicField.{name}", + details={"target_name": target, "value_type": value_type}, + ) + ) + missing = sorted(set(mappings).difference(source)) + for name in missing: + diagnostics.append( + _diagnostic( + "info", + "dynamic_field_not_returned", + "A configured dynamic field was not returned for this ticket.", + object_ref=external_id, + field=f"DynamicField.{name}", + ) + ) + return result, diagnostics + + +def _coerce_dynamic(value: Any, value_type: str) -> Any: + if value_type == "json": + return value + if value_type == "string": + return None if value is None else str(value) + if value_type == "number": + return float(value) + if value_type == "boolean": + if isinstance(value, bool): + return value + text = str(value).strip().casefold() + if text in {"1", "true", "yes", "y"}: + return True + if text in {"0", "false", "no", "n"}: + return False + raise ValueError("invalid boolean") + if value_type == "date": + parsed = _parse_datetime(value) + if parsed is None: + raise ValueError("invalid date") + return parsed.isoformat() + raise ValueError("unsupported type") + + +def _effective_acl( + profile: ConnectorServiceDeskProfile, + queue_mapping: Mapping[str, Any] | None, + *, + raw: Mapping[str, Any], +) -> tuple[str, list[str], str]: + provider_visibility = _optional_text(raw.get("GovOPlaNVisibility")) + provider_acl = raw.get("GovOPlaNACL") + if provider_visibility in {"tenant", "restricted"}: + tokens = [str(value).strip() for value in _sequence(provider_acl) if str(value).strip()] + if provider_visibility == "tenant" or tokens: + return provider_visibility, tokens, "provider" + visibility = str( + (queue_mapping or {}).get("visibility") or profile.default_visibility + ) + tokens = list((queue_mapping or {}).get("acl_tokens") or profile.default_acl_tokens) + _validate_acl(visibility, tokens) + return visibility, tokens, "queue_mapping" if queue_mapping else "configured_default" + + +def _refresh_embedded_references( + data: dict[str, Any], profile: ConnectorServiceDeskProfile +) -> None: + system = profile.product if profile.product != "unknown" else "znuny_otrs" + for collection_name in ("articles", "attachments"): + for value in data.get(collection_name) or (): + if not isinstance(value, dict): + continue + reference = value.get("reference") + if not isinstance(reference, dict): + continue + reference.update( + { + "system": system, + "maturity": profile.discovered_maturity, + "authority_mode": profile.source_authority_mode, + "connector_id": profile.id, + } + ) + + +def _queue_mapping( + profile: ConnectorServiceDeskProfile, source_queue: str +) -> Mapping[str, Any] | None: + folded = source_queue.casefold() + return next( + ( + value + for value in profile.queue_mappings or () + if str(value.get("source_queue") or "").casefold() == folded + ), + None, + ) + + +def _upsert_object( + session: Session, + profile: ConnectorServiceDeskProfile, + mapped: Mapping[str, Any], + *, + cursor: str | None, +) -> tuple[ConnectorServiceDeskObject, dict[str, Any]]: + row = _object_by_external_id(session, profile.id, str(mapped["external_id"])) + effect = "created" + if row is None: + row = ConnectorServiceDeskObject( + tenant_id=profile.tenant_id, + profile_id=profile.id, + object_type="ticket", + external_id=str(mapped["external_id"]), + external_ticket_number=_optional_text(mapped.get("external_ticket_number")), + title=str(mapped["title"]), + canonical_url=_optional_text(mapped.get("canonical_url")), + status=str(mapped["status"]), + source_revision=str(mapped["source_revision"]), + content_hash=str(mapped["content_hash"]), + visibility=str(mapped["visibility"]), + acl_tokens=list(mapped["acl_tokens"]), + mapped_data=dict(mapped["mapped_data"]), + provenance=dict(mapped["provenance"]), + change_cursor=cursor, + source_updated_at=mapped.get("source_updated_at"), + observed_at=mapped.get("observed_at") or _now(), + ) + session.add(row) + elif ( + row.source_revision == mapped["source_revision"] + and row.content_hash == mapped["content_hash"] + and row.status == mapped["status"] + ): + effect = "unchanged" + row.change_cursor = cursor or row.change_cursor + row.observed_at = _now() + else: + effect = "updated" if row.status != "deleted" else "restored" + for field_name in ( + "external_ticket_number", + "title", + "canonical_url", + "status", + "source_revision", + "content_hash", + "visibility", + "source_updated_at", + ): + setattr(row, field_name, mapped.get(field_name)) + row.acl_tokens = list(mapped["acl_tokens"]) + row.mapped_data = dict(mapped["mapped_data"]) + row.provenance = dict(mapped["provenance"]) + row.change_cursor = cursor + row.observed_at = _now() + row.resource_revision += 1 + return row, { + "effect": effect, + "external_id": row.external_id, + "ticket_number": row.external_ticket_number, + "title": row.title, + "source_revision": row.source_revision, + "status": row.status, + } + + +def _reconcile_deleted( + session: Session, + profile: ConnectorServiceDeskProfile, + live_ids: set[str], +) -> list[ConnectorServiceDeskObject]: + rows = tuple( + session.scalars( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.tenant_id == profile.tenant_id, + ConnectorServiceDeskObject.profile_id == profile.id, + ConnectorServiceDeskObject.object_type == "ticket", + ConnectorServiceDeskObject.status != "deleted", + ) + ) + ) + deleted: list[ConnectorServiceDeskObject] = [] + for row in rows: + if row.external_id in live_ids: + continue + row.status = "deleted" + row.resource_revision += 1 + row.observed_at = _now() + row.content_hash = _hash( + {"external_id": row.external_id, "status": "deleted", "source_revision": row.source_revision} + ) + deleted.append(row) + return deleted + + +def _exclude_object( + session: Session, + profile: ConnectorServiceDeskProfile, + *, + external_id: str, + source_revision: str, +) -> ConnectorServiceDeskObject | None: + row = _object_by_external_id(session, profile.id, external_id) + if row is None or row.status == "deleted": + return None + row.status = "deleted" + row.source_revision = source_revision + row.resource_revision += 1 + row.observed_at = _now() + row.content_hash = _hash( + { + "external_id": row.external_id, + "status": "deleted", + "source_revision": row.source_revision, + } + ) + return row + + +def _invalidate_projection_rows( + rows: Sequence[ConnectorServiceDeskObject], +) -> int: + invalidated = 0 + for row in rows: + if row.status == "deleted": + continue + row.status = "deleted" + row.resource_revision += 1 + row.observed_at = _now() + row.content_hash = _hash( + { + "external_id": row.external_id, + "status": "deleted", + "source_revision": row.source_revision, + } + ) + invalidated += 1 + return invalidated + + +def _update_search( + session: Session, + principal: ApiPrincipal, + rows: Sequence[ConnectorServiceDeskObject], + *, + profile: ConnectorServiceDeskProfile, + registry: object | None, + diagnostics: list[dict[str, Any]], + source_active: bool = True, +) -> None: + writer = search_index_writer(registry) + if writer is None: + diagnostics.append( + _diagnostic( + "info", + "search_unavailable", + "Search is not installed; external tickets remain available through the connector.", + ) + ) + return + from govoplan_connectors.backend.service_desk_search import search_document + + search_enabled = ( + source_active + and "search" in profile.capabilities + and _supports(profile.desired_maturity, "search") + ) + for row in rows: + try: + if row.status == "deleted" or not search_enabled: + writer.delete_document( + session, + principal, + tenant_id=row.tenant_id, + module_id="connectors", + resource_type=SERVICE_DESK_RESOURCE_TYPE, + resource_id=row.id, + ) + else: + writer.upsert_document( + session, + principal, + document=search_document(session, row, profile), + ) + except Exception: + diagnostics.append( + _diagnostic( + "warning", + "search_update_deferred", + "The Search projection could not be updated immediately; authorization rechecks fail closed and the next rebuild will reconcile it.", + object_ref=row.external_id, + retryable=True, + ) + ) + + +def _update_payload( + profile: ConnectorServiceDeskProfile, + payload: ServiceDeskTicketUpdateRequest, +) -> dict[str, Any]: + result: dict[str, Any] = {} + for field_name, provider_name in ( + ("title", "Title"), + ("queue", "Queue"), + ("state", "State"), + ("priority", "Priority"), + ("owner", "Owner"), + ("responsible", "Responsible"), + ): + value = getattr(payload, field_name) + if value is not None: + result[provider_name] = value + allowed_dynamic = { + str(value.get("target_name") or value.get("source_name")): str(value.get("source_name")) + for value in profile.dynamic_field_mappings or () + if bool(value.get("include", True)) + } + dynamic: list[dict[str, Any]] = [] + for name, value in payload.dynamic_fields.items(): + source_name = allowed_dynamic.get(name) + if source_name is None: + raise ServiceDeskConnectorError( + "dynamic_field_not_governed", + f"Dynamic field {name!r} has no writable governed mapping.", + ) + dynamic.append({"Name": source_name, "Value": value}) + if dynamic: + result["DynamicField"] = dynamic + return result + + +def _profile_item( + item: ConnectorServiceDeskProfile, + configuration: ConnectorConfiguration, +) -> ServiceDeskProfileItem: + return ServiceDeskProfileItem( + id=item.id, + tenant_id=item.tenant_id, + configuration_id=item.configuration_id, + status=item.status, + integration_mode=item.integration_mode, + product=item.product, + product_version=item.product_version, + desired_maturity=item.desired_maturity, + discovered_maturity=item.discovered_maturity, + source_authority_mode=item.source_authority_mode, + default_visibility=item.default_visibility, + default_acl_tokens=list(item.default_acl_tokens or ()), + routes=ServiceDeskRouteMapping.model_validate(item.routes or {}), + queue_mappings=[ + ServiceDeskQueueMapping.model_validate(value) + for value in item.queue_mappings or () + ], + dynamic_field_mappings=[ + ServiceDeskDynamicFieldMapping.model_validate(value) + for value in item.dynamic_field_mappings or () + ], + capabilities=list(item.capabilities or ()), + discovery_revision=item.discovery_revision, + discovered_configuration_revision=item.discovered_configuration_revision, + discovered_configuration_hash=item.discovered_configuration_hash, + health_status=item.health_status, + health_details=dict(item.health_details or {}), + discovered_at=item.discovered_at, + last_sync_cursor=item.last_sync_cursor, + last_high_watermark=item.last_high_watermark, + resource_revision=item.resource_revision, + credential_reference_present=bool(configuration.credential_ref), + endpoint_configured=bool(configuration.endpoint_url), + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _object_item(row: ConnectorServiceDeskObject) -> ServiceDeskObjectItem: + return ServiceDeskObjectItem( + id=row.id, + profile_id=row.profile_id, + object_type=row.object_type, + external_id=row.external_id, + external_ticket_number=row.external_ticket_number, + title=row.title, + canonical_url=row.canonical_url, + status=row.status, + source_revision=row.source_revision, + visibility=row.visibility, + acl_tokens=list(row.acl_tokens or ()), + mapped_data=dict(row.mapped_data or {}), + provenance=dict(row.provenance or {}), + source_updated_at=row.source_updated_at, + observed_at=row.observed_at, + resource_revision=row.resource_revision, + ) + + +def _run_item(row: ConnectorServiceDeskSyncRun) -> ServiceDeskSyncRunItem: + return ServiceDeskSyncRunItem( + id=row.id, + profile_id=row.profile_id, + mode=row.mode, + idempotency_key=row.idempotency_key, + status=row.status, + cursor_before=row.cursor_before, + cursor_after=row.cursor_after, + high_watermark=row.high_watermark, + counts={key: int(value) for key, value in dict(row.counts or {}).items()}, + effects=list(row.effects or ()), + diagnostics=[ + ServiceDeskDiagnostic.model_validate(value) + for value in row.diagnostics or () + ], + provenance=dict(row.provenance or {}), + started_at=row.started_at, + finished_at=row.finished_at, + created_at=row.created_at, + ) + + +def _configuration( + session: Session, + tenant_id: str, + configuration_id: str, + *, + active: bool, +) -> ConnectorConfiguration: + row = session.scalar( + select(ConnectorConfiguration).where( + ConnectorConfiguration.tenant_id == tenant_id, + ConnectorConfiguration.id == configuration_id, + ) + ) + if row is None: + raise ServiceDeskConnectorError( + "configuration_not_found", "Connector configuration not found." + ) + if active and row.status != "active": + raise ServiceDeskConnectorError( + "configuration_inactive", "Connector configuration is not active." + ) + return row + + +def _profile( + session: Session, tenant_id: str, profile_id: str +) -> ConnectorServiceDeskProfile: + row = session.scalar( + select(ConnectorServiceDeskProfile).where( + ConnectorServiceDeskProfile.tenant_id == tenant_id, + ConnectorServiceDeskProfile.id == profile_id, + ) + ) + if row is None: + raise ServiceDeskConnectorError( + "profile_not_found", "Service-desk connector profile not found." + ) + return row + + +def _configurations( + session: Session, + tenant_id: str, + profiles: Sequence[ConnectorServiceDeskProfile], +) -> dict[str, ConnectorConfiguration]: + ids = tuple(dict.fromkeys(value.configuration_id for value in profiles)) + if not ids: + return {} + return { + row.id: row + for row in session.scalars( + select(ConnectorConfiguration).where( + ConnectorConfiguration.tenant_id == tenant_id, + ConnectorConfiguration.id.in_(ids), + ) + ) + } + + +def _assert_service_desk_configuration( + session: Session, configuration: ConnectorConfiguration +) -> None: + definition = session.scalar( + select(ConnectorDefinition).where( + ConnectorDefinition.id == configuration.definition_id, + ConnectorDefinition.tenant_id == configuration.tenant_id, + ) + ) + specification = dict(configuration.effective_configuration or {}) + provider = str(specification.get("provider") or "").casefold() + protocol = str(specification.get("protocol") or "").casefold() + if provider not in {"znuny", "otrs", "znuny_otrs", "service_desk"} or protocol not in { + "rest", + "generic_interface_rest", + "otrs_generic_interface_rest", + }: + raise ServiceDeskConnectorError( + "configuration_type_invalid", + "The selected configuration is not a Znuny/OTRS GenericInterface REST connector.", + ) + if definition is None or definition.status != "active": + raise ServiceDeskConnectorError( + "definition_unavailable", "The connector definition is unavailable." + ) + + +def _credential( + session: Session, + principal: ApiPrincipal, + configuration: ConnectorConfiguration, +) -> dict[str, Any] | None: + if not configuration.credential_ref: + return None + try: + value = resolve_credential_envelope( + session, + credential_id=configuration.credential_ref, + context=CredentialAccessContext( + tenant_id=principal.tenant_id, + user_id=_actor_id(principal), + group_ids=frozenset(principal.principal.group_ids), + target_scope_type="tenant", + target_scope_id=principal.tenant_id, + module_id="connectors", + server_ref=_endpoint(configuration), + ), + ) + except CredentialEnvelopeError as exc: + raise ServiceDeskConnectorError( + "credential_unavailable", + "The connector credential is inactive, unavailable, or outside its approved scope.", + ) from exc + return {**dict(value.public_data), **dict(value.secret_data)} + + +def _run_replay( + session: Session, + *, + tenant_id: str, + profile_id: str, + idempotency_key: str, + request_hash: str, +) -> ConnectorServiceDeskSyncRun | None: + row = session.scalar( + select(ConnectorServiceDeskSyncRun).where( + ConnectorServiceDeskSyncRun.tenant_id == tenant_id, + ConnectorServiceDeskSyncRun.profile_id == profile_id, + ConnectorServiceDeskSyncRun.idempotency_key == idempotency_key, + ) + ) + if row is None: + return None + if row.request_hash != request_hash: + raise ServiceDeskConnectorError( + "idempotency_conflict", + "This idempotency key was already used with a different request.", + ) + if row.status in {"running", "prepared", "outcome_unknown"}: + raise ServiceDeskConnectorError( + "operation_unresolved", + "This connector operation is active or unresolved; reconcile it before retrying.", + ) + return row + + +def _profile_policy_hash(profile: ConnectorServiceDeskProfile) -> str: + return _hash( + { + "integration_mode": profile.integration_mode, + "desired_maturity": profile.desired_maturity, + "source_authority_mode": profile.source_authority_mode, + "default_visibility": profile.default_visibility, + "default_acl_tokens": profile.default_acl_tokens, + "routes": profile.routes, + "queue_mappings": profile.queue_mappings, + "dynamic_field_mappings": profile.dynamic_field_mappings, + } + ) + + +def _object_by_external_id( + session: Session, profile_id: str, external_id: str +) -> ConnectorServiceDeskObject | None: + return session.scalar( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.profile_id == profile_id, + ConnectorServiceDeskObject.object_type == "ticket", + ConnectorServiceDeskObject.external_id == external_id, + ) + ) + + +def _validate_profile_state(profile: ConnectorServiceDeskProfile) -> None: + allowed = { + "link": {"linked_reference"}, + "import": {"external_authoritative", "external_mirror"}, + "synchronize": {"external_authoritative", "governed_sync"}, + } + if profile.source_authority_mode not in allowed.get(profile.integration_mode, set()): + raise ServiceDeskConnectorError( + "authority_mode_invalid", + "The source-of-truth mode is incompatible with the integration mode.", + ) + _validate_acl(profile.default_visibility, profile.default_acl_tokens) + order = tuple(INTEGRATION_MATURITY_ORDER) + minimum = {"link": "link", "import": "read", "synchronize": "synchronize"}[ + profile.integration_mode + ] + maximum = {"link": "search", "import": "read", "synchronize": "synchronize"}[ + profile.integration_mode + ] + if order.index(profile.desired_maturity) < order.index(minimum) or order.index( + profile.desired_maturity + ) > order.index(maximum): + raise ServiceDeskConnectorError( + "maturity_mode_invalid", + f"{profile.integration_mode} mode requires maturity between {minimum} and {maximum}.", + ) + ServiceDeskRouteMapping.model_validate(profile.routes or {}) + + +def _assert_sync_maturity( + profile: ConnectorServiceDeskProfile, + configuration: ConnectorConfiguration, +) -> None: + if profile.discovered_at is None: + raise ServiceDeskConnectorError( + "discovery_required", "Discover the provider before synchronizing." + ) + _assert_discovery_configuration(profile, configuration) + required = { + "link": "link", + "import": "read", + "synchronize": "synchronize", + }[profile.integration_mode] + if not _supports(profile.discovered_maturity, required) or not _supports( + profile.desired_maturity, required + ): + raise ServiceDeskConnectorError( + "maturity_insufficient", + f"The configured and discovered maturity must support {required} mode.", + ) + + +def _assert_discovery_configuration( + profile: ConnectorServiceDeskProfile, + configuration: ConnectorConfiguration, +) -> None: + if ( + profile.discovered_configuration_revision != configuration.resource_revision + or profile.discovered_configuration_hash != configuration.effective_hash + ): + raise ServiceDeskConnectorError( + "rediscovery_required", + "The governed connector configuration changed; rediscover the provider before synchronizing or updating tickets.", + ) + + +def _sync_mode( + profile: ConnectorServiceDeskProfile, payload: ServiceDeskSyncRequest +) -> str: + if profile.integration_mode == "import": + if payload.mode == "delta": + raise ServiceDeskConnectorError( + "delta_not_allowed", + "Import mode is snapshot-only; use a full import with a new deliberate key.", + ) + return "full" + if profile.integration_mode == "link" and payload.mode == "delta": + raise ServiceDeskConnectorError( + "delta_not_allowed", "Link mode refreshes bounded full identity sets." + ) + if payload.mode == "delta" and _cursor_kind(profile.last_sync_cursor) != "delta": + raise ServiceDeskConnectorError( + "full_synchronization_required", + "Delta synchronization requires a completed full synchronization for the current profile.", + ) + if payload.mode == "auto": + if profile.last_sync_cursor: + kind = _cursor_kind(profile.last_sync_cursor) + if kind == "full": + return "full" + if kind == "delta" and profile.integration_mode == "synchronize": + return "delta" + return "full" + return payload.mode + + +def _cursor_kind(cursor: str | None) -> str | None: + if not cursor: + return None + try: + value = json.loads(cursor) + except json.JSONDecodeError: + return None + return str(value.get("kind")) if isinstance(value, Mapping) and value.get("kind") else None + + +def _effective_sync_cursor( + profile: ConnectorServiceDeskProfile, + *, + mode: str, + complete: bool, + next_cursor: str | None, + high_watermark: str | None, +) -> str | None: + if mode == "full" and complete and profile.integration_mode == "synchronize": + return json.dumps( + { + "kind": "delta", + "changed": high_watermark, + "seen": [], + }, + sort_keys=True, + separators=(",", ":"), + ) + return next_cursor + + +def _canonical_url( + profile: ConnectorServiceDeskProfile, + ticket_id: str, + ticket_number: str | None, +) -> str | None: + template = _optional_text(dict(profile.routes or {}).get("ticket_web_url_template")) + if not template: + return None + return template.replace("{ticket_id}", quote(ticket_id, safe="")).replace( + "{ticket_number}", quote(ticket_number or ticket_id, safe="") + ) + + +def _reference_fact( + raw: Mapping[str, Any], + name_key: str, + id_key: str, + *, + extra: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + return { + "name": _optional_text(raw.get(name_key)), + "external_id": _optional_text(raw.get(id_key)), + **dict(extra or {}), + } + + +def _principal_fact( + raw: Mapping[str, Any], name_key: str, id_key: str +) -> dict[str, Any]: + return { + "display_name": _optional_text(raw.get(name_key)), + "external_id": _optional_text(raw.get(id_key)), + } + + +def _reference( + profile: ConnectorServiceDeskProfile, + *, + object_type: str, + object_id: str, + version: str | None, + observed_at: datetime, + metadata: Mapping[str, Any], +) -> dict[str, object]: + return ExternalObjectReference( + system=profile.product if profile.product != "unknown" else "znuny_otrs", + object_type=object_type, + object_id=object_id, + maturity=profile.discovered_maturity, + authority_mode=profile.source_authority_mode, + connector_id=profile.id, + version=version, + observed_at=observed_at, + metadata=dict(metadata), + ).to_dict() + + +def _source_revision(raw: Mapping[str, Any]) -> str: + return ( + _optional_text(raw.get("Changed") or raw.get("ChangeTime") or raw.get("Updated")) + or _hash(raw) + ) + + +def _external_id(raw: Mapping[str, Any]) -> str: + value = _optional_text(raw.get("TicketID") or raw.get("ID")) + if not value: + raise ServiceDeskConnectorError( + "ticket_identity_missing", "The provider ticket has no stable TicketID." + ) + return value + + +def _content_hash( + data: Mapping[str, Any], *, visibility: str, acl_tokens: Sequence[str] +) -> str: + return _hash( + { + "mapped_data": _stable_mapped_content(data), + "visibility": visibility, + "acl_tokens": list(acl_tokens), + } + ) + + +def _stable_mapped_content(value: Any, *, reference: bool = False) -> Any: + if isinstance(value, Mapping): + reference = reference or { + "system", + "object_type", + "object_id", + "maturity", + "authority_mode", + }.issubset(str(key) for key in value) + return { + str(key): _stable_mapped_content( + item, + reference=str(key) == "reference", + ) + for key, item in value.items() + if not (reference and str(key) == "observed_at") + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [_stable_mapped_content(item, reference=reference) for item in value] + return value + + +def _validate_acl(visibility: str, acl_tokens: Sequence[str]) -> None: + if visibility not in {"tenant", "restricted"}: + raise ServiceDeskConnectorError("visibility_invalid", "Unsupported visibility.") + if visibility == "restricted" and not [value for value in acl_tokens if str(value).strip()]: + raise ServiceDeskConnectorError( + "acl_required", "Restricted visibility requires at least one ACL token." + ) + + +def _endpoint(configuration: ConnectorConfiguration) -> str: + value = _optional_text(configuration.endpoint_url) + if not value: + raise ServiceDeskConnectorError( + "endpoint_missing", "The connector configuration has no endpoint URL." + ) + return value + + +def _audit( + session: Session, + principal: ApiPrincipal, + *, + action: str, + object_type: str, + object_id: str, + details: Mapping[str, Any], +) -> None: + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=getattr(principal.user, "id", None), + api_key_id=principal.api_key_id, + action=f"connectors.service_desk.{action}", + object_type=object_type, + object_id=object_id, + details=dict(details), + ) + + +def _diagnostic( + severity: str, + code: str, + message: str, + *, + object_ref: str | None = None, + field: str | None = None, + retryable: bool = False, + details: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + return { + "severity": severity, + "code": code, + "message": message, + "object_ref": object_ref, + "field": field, + "retryable": retryable, + "details": dict(details or {}), + } + + +def _actor_id(principal: ApiPrincipal) -> str | None: + return _optional_text( + getattr(principal.user, "id", None) + or getattr(principal.account, "id", None) + or principal.principal.account_id + ) + + +def _require_scope(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise ServiceDeskConnectorError("forbidden", f"Missing required scope: {scope}") + + +def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: + if not any(has_scope(principal, scope) for scope in scopes): + raise ServiceDeskConnectorError( + "forbidden", f"One of these scopes is required: {', '.join(scopes)}" + ) + + +def _supports(current: str, required: str) -> bool: + try: + return INTEGRATION_MATURITY_ORDER.index(current) >= INTEGRATION_MATURITY_ORDER.index(required) # type: ignore[arg-type] + except ValueError: + return False + + +def _mapping_list(value: object) -> list[Mapping[str, Any]]: + if isinstance(value, Mapping): + return [value] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _sequence(value: object) -> Sequence[Any]: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return value + if value is None: + return () + return (value,) + + +def _boolean(value: object) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + return str(value).strip().casefold() in {"1", "true", "yes", "y"} + + +def _integer(value: object) -> int | None: + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def _parse_datetime(value: object) -> datetime | None: + text = _optional_text(value) + if not text: + return None + normalized = text.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): + try: + parsed = datetime.strptime(text, pattern) + break + except ValueError: + continue + else: + return None + return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc) + + +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 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +__all__ = [ + "ExternalServiceDeskCapability", + "SERVICE_DESK_ADMIN_SCOPE", + "SERVICE_DESK_CAPABILITY", + "SERVICE_DESK_INTERFACE_VERSION", + "SERVICE_DESK_PROVIDER_ID", + "SERVICE_DESK_READ_SCOPE", + "SERVICE_DESK_RESOURCE_TYPE", + "SERVICE_DESK_SYNC_SCOPE", + "SERVICE_DESK_UPDATE_SCOPE", + "ServiceDeskConnectorError", + "create_profile", + "discover_profile", + "list_objects", + "list_profiles", + "list_runs", + "synchronize_profile", + "update_profile", + "update_ticket", +] diff --git a/src/govoplan_connectors/backend/service_desk_schemas.py b/src/govoplan_connectors/backend/service_desk_schemas.py new file mode 100644 index 0000000..f258ca3 --- /dev/null +++ b/src/govoplan_connectors/backend/service_desk_schemas.py @@ -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")] diff --git a/src/govoplan_connectors/backend/service_desk_search.py b/src/govoplan_connectors/backend/service_desk_search.py new file mode 100644 index 0000000..2c6c307 --- /dev/null +++ b/src/govoplan_connectors/backend/service_desk_search.py @@ -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", +] diff --git a/src/govoplan_connectors/backend/service_desk_transport.py b/src/govoplan_connectors/backend/service_desk_transport.py new file mode 100644 index 0000000..68bf540 --- /dev/null +++ b/src/govoplan_connectors/backend/service_desk_transport.py @@ -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", +] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 452c0f6..e5343d8 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -24,7 +24,7 @@ class ConnectorsMigrationTests(unittest.TestCase): try: with engine.connect() as connection: self.assertIn( - "b9e0f1a2c3d4", + "c0f1a2b3c4d5", set(MigrationContext.configure(connection).get_current_heads()), ) self.assertTrue( @@ -39,6 +39,9 @@ class ConnectorsMigrationTests(unittest.TestCase): "connector_knowledge_profiles", "connector_knowledge_objects", "connector_knowledge_sync_runs", + "connector_service_desk_profiles", + "connector_service_desk_objects", + "connector_service_desk_sync_runs", }.issubset(inspect(connection).get_table_names()) ) finally: diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py index a4eead7..7228353 100644 --- a/tests/test_provider_state.py +++ b/tests/test_provider_state.py @@ -7,9 +7,13 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_connectors.backend.db.models import ( + ConnectorConfiguration, ConnectorKnowledgeObject, ConnectorKnowledgeProfile, ConnectorKnowledgeSyncRun, + ConnectorServiceDeskObject, + ConnectorServiceDeskProfile, + ConnectorServiceDeskSyncRun, ConnectorSanctionsAcquisitionRun, ConnectorSanctionsSnapshot, ConnectorTabularSource, @@ -18,9 +22,11 @@ from govoplan_connectors.backend.manifest import manifest from govoplan_connectors.backend.provider_state import ( KNOWLEDGE_PROVIDER_ID, SANCTIONS_PROVIDER_ID, + SERVICE_DESK_PROVIDER_ID, TABULAR_PROVIDER_ID, knowledge_provider_states, sanctions_provider_states, + service_desk_provider_states, tabular_provider_states, ) from govoplan_core.core.provider_governance import ExternalProviderStateContext @@ -109,7 +115,12 @@ class ConnectorsProviderStateTests(unittest.TestCase): self.assertNotIn("secret-source-name", rendered) self.assertNotIn("source.example.test", rendered) self.assertEqual( - {TABULAR_PROVIDER_ID, SANCTIONS_PROVIDER_ID, KNOWLEDGE_PROVIDER_ID}, + { + TABULAR_PROVIDER_ID, + SANCTIONS_PROVIDER_ID, + KNOWLEDGE_PROVIDER_ID, + SERVICE_DESK_PROVIDER_ID, + }, {item.provider_id for item in manifest.external_provider_state_providers}, ) @@ -172,6 +183,95 @@ class ConnectorsProviderStateTests(unittest.TestCase): self.assertNotIn("group:secret-acl", rendered) self.assertNotIn("configuration-secret", rendered) + def test_service_desk_state_reports_recovery_without_ticket_or_acl_data(self) -> None: + now = datetime.now(UTC) + configuration = ConnectorConfiguration( + id="configuration-secret", + tenant_id="tenant-1", + definition_id="definition-1", + name="Secret service desk", + status="active", + base_definition_revision=1, + local_overrides={}, + protected_paths=[], + effective_configuration={}, + effective_hash="configuration-hash", + resource_revision=3, + ambiguity_policy="manual_review", + ) + profile = ConnectorServiceDeskProfile( + id="service-desk-profile-1", + tenant_id="tenant-1", + configuration_id="configuration-secret", + status="active", + integration_mode="synchronize", + product="znuny", + product_version="7.1.4", + desired_maturity="synchronize", + discovered_maturity="synchronize", + source_authority_mode="governed_sync", + default_visibility="restricted", + default_acl_tokens=["group:secret-acl"], + routes={"secret": "route"}, + queue_mappings=[{"secret": "queue"}], + dynamic_field_mappings=[{"secret": "field"}], + capabilities=["read", "search", "synchronize", "publish"], + discovered_configuration_revision=3, + discovered_configuration_hash="configuration-hash", + health_status="healthy", + discovered_at=now, + ) + ticket = ConnectorServiceDeskObject( + id="service-desk-object-1", + tenant_id="tenant-1", + profile_id=profile.id, + object_type="ticket", + external_id="42", + external_ticket_number="secret-number", + title="Secret ticket title", + status="active", + source_revision="2026-08-22T10:00:00Z", + content_hash="e" * 64, + visibility="restricted", + acl_tokens=["group:secret-acl"], + mapped_data={"secret": "ticket content"}, + provenance={"secret": "provider evidence"}, + observed_at=now, + ) + run = ConnectorServiceDeskSyncRun( + id="service-desk-run-1", + tenant_id="tenant-1", + profile_id=profile.id, + mode="update", + idempotency_key="secret-key", + request_hash="f" * 64, + status="outcome_unknown", + started_at=now, + finished_at=now, + ) + self.session.add_all((configuration, profile, ticket, run)) + self.session.commit() + + state = service_desk_provider_states( + ExternalProviderStateContext(session=self.session, tenant_id="tenant-1") + )[0] + + self.assertEqual(SERVICE_DESK_PROVIDER_ID, state.provider_id) + self.assertTrue(state.metrics["discovery_current"]) + self.assertEqual("pending", state.conflict) + self.assertEqual("attention", state.recovery) + rendered = str(state.to_dict()) + for secret in ( + "configuration-secret", + "secret-number", + "Secret ticket title", + "group:secret-acl", + "ticket content", + "provider evidence", + "secret-key", + ): + self.assertNotIn(secret, rendered) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_service_desk_connector.py b/tests/test_service_desk_connector.py new file mode 100644 index 0000000..f5a1586 --- /dev/null +++ b/tests/test_service_desk_connector.py @@ -0,0 +1,806 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.search import SearchAuthorizationRequest, SearchBackfillRequest +from govoplan_core.core.recovery import RecoveryOperation, RecoveryStatus +from govoplan_core.core.runtime_coordination import ( + RuntimeIdentity, + bind_process_runtime_identity, +) +from govoplan_core.db.base import Base +from govoplan_connectors.backend.db.models import ( + ConnectorConfiguration, + ConnectorDefinition, + ConnectorDefinitionRevision, + ConnectorServiceDeskObject, + ConnectorServiceDeskProfile, + ConnectorServiceDeskSyncRun, +) +from govoplan_connectors.backend.service_desk_connector import ( + SERVICE_DESK_PROVIDER_ID, + SERVICE_DESK_RESOURCE_TYPE, + ServiceDeskConnectorError, + create_profile, + discover_profile, + list_objects, + synchronize_profile, + update_profile, + update_ticket, +) +from govoplan_connectors.backend.service_desk_schemas import ( + ServiceDeskDynamicFieldMapping, + ServiceDeskProfileCreateRequest, + ServiceDeskProfileUpdateRequest, + ServiceDeskQueueMapping, + ServiceDeskRouteMapping, + ServiceDeskSyncRequest, + ServiceDeskTicketUpdateRequest, +) +from govoplan_connectors.backend.service_desk_search import ExternalServiceDeskSearchSource +from govoplan_connectors.backend.service_desk_transport import ( + ServiceDeskChangeBatch, + ServiceDeskTransportError, + ServiceDeskUpdateResult, +) + + +ALL_SCOPES = frozenset( + { + "connectors:service_desk:read", + "connectors:service_desk:admin", + "connectors:service_desk:sync", + "connectors:service_desk:update", + } +) + + +def principal( + tenant_id: str = "tenant-1", + *, + groups: frozenset[str] = frozenset({"agents"}), +) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id=tenant_id, + scopes=ALL_SCOPES, + group_ids=groups, + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="account-1"), + ) + + +def ticket( + *, + ticket_id: str = "42", + revision: str = "2026-08-22T10:00:00Z", + queue: str = "Residents", + acl: list[str] | None = None, +) -> dict[str, object]: + return { + "TicketID": ticket_id, + "TicketNumber": f"20260822{ticket_id}", + "Title": "Resident parking inquiry", + "Queue": queue, + "QueueID": "3", + "State": "open", + "StateID": "4", + "Priority": "3 normal", + "PriorityID": "3", + "Owner": "agent.a", + "OwnerID": "7", + "CustomerUserID": "citizen-17", + "CustomerID": "organization-9", + "Changed": revision, + "GovOPlaNVisibility": "restricted", + "GovOPlaNACL": acl or ["group:agents"], + "DynamicField": [{"Name": "PermitKind", "Value": "resident"}], + "Article": [ + { + "ArticleID": "71", + "Subject": "Question", + "Body": "Please verify the submitted address.", + "Created": "2026-08-22T09:58:00Z", + "Attachment": [ + { + "AttachmentID": "91", + "Filename": "address.pdf", + "Filesize": 1234, + "ContentType": "application/pdf", + "ContentBase64": "not-retained", + } + ], + } + ], + } + + +class StaticTransport: + def __init__(self) -> None: + self.batches: list[ServiceDeskChangeBatch] = [] + self.change_calls: list[dict[str, object]] = [] + self.update_calls = 0 + self.update_result = ServiceDeskUpdateResult( + ticket={ + **ticket(revision="2026-08-22T11:00:00Z"), + "State": "pending reminder", + }, + revision="2026-08-22T11:00:00Z", + evidence={"verified": True}, + ) + self.update_error: ServiceDeskTransportError | None = None + + def discover(self, **kwargs): + del kwargs + return { + "product": "znuny", + "product_version": "7.1.4", + "api_family": "generic_interface_rest", + "capabilities": [ + "discover", + "link", + "search", + "read", + "synchronize", + "publish", + ], + "maturity": "synchronize", + "health_status": "healthy", + "revision": "discovery-1", + "diagnostics": [], + "evidence": {"fixture": True}, + } + + def changes(self, **kwargs): + self.change_calls.append(dict(kwargs)) + if not self.batches: + raise AssertionError("No deterministic service-desk batch remains") + return self.batches.pop(0) + + def update_ticket(self, **kwargs): + del kwargs + self.update_calls += 1 + if self.update_error is not None: + raise self.update_error + return self.update_result + + +class RecordingSearchWriter: + def __init__(self) -> None: + self.upserts: list[object] = [] + self.deletes: list[str] = [] + + def upsert_document(self, _session, _principal, *, document) -> None: + self.upserts.append(document) + + def delete_document( + self, + _session, + _principal, + *, + tenant_id, + module_id, + resource_type, + resource_id, + ) -> bool: + del tenant_id, module_id, resource_type + self.deletes.append(resource_id) + return True + + def enqueue_change(self, _session, *, change) -> bool: + del change + return True + + +class SearchRegistry: + def __init__(self, writer: RecordingSearchWriter) -> None: + self.writer = writer + + def has_capability(self, name: str) -> bool: + return name == "search.index_writer" + + def capability(self, name: str): + if not self.has_capability(name): + raise KeyError(name) + return self.writer + + +class ServiceDeskConnectorTests(unittest.TestCase): + def setUp(self) -> None: + bind_process_runtime_identity( + RuntimeIdentity( + installation_id="service-desk-connector-tests", + node_id="node-1", + incarnation="incarnation-1", + role="worker", + software_version="test", + composition_hash="a" * 64, + ) + ) + self.engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.session = Session(self.engine) + self.audit = patch("govoplan_connectors.backend.service_desk_connector.audit_event") + self.audit.start() + self.credential = patch( + "govoplan_connectors.backend.service_desk_connector._credential", + return_value={"user_login": "connector", "password": "secret"}, + ) + self.credential.start() + self.transport = StaticTransport() + self.configuration_id = self._seed_configuration() + self.profile_id = self._create_profile() + + def tearDown(self) -> None: + bind_process_runtime_identity(None) + self.credential.stop() + self.audit.stop() + self.session.close() + self.engine.dispose() + + def _seed_configuration(self) -> str: + definition = ConnectorDefinition( + id="definition-1", + tenant_id="tenant-1", + definition_key="service-desk.znuny", + name="Znuny", + status="active", + current_revision=1, + local_definition=True, + ) + self.session.add(definition) + self.session.add( + ConnectorDefinitionRevision( + id="definition-revision-1", + definition_id=definition.id, + revision=1, + specification={"provider": "znuny", "protocol": "generic_interface_rest"}, + definition_hash="definition-hash", + origin="local", + created_by="account-1", + ) + ) + configuration = ConnectorConfiguration( + id="configuration-1", + tenant_id="tenant-1", + definition_id=definition.id, + name="Institutional service desk", + status="active", + endpoint_url="https://support.example.invalid/znuny/nph-genericinterface.pl/Webservice/GovOPlaN", + credential_ref="credential-envelope-1", + base_definition_revision=1, + local_overrides={}, + protected_paths=[], + effective_configuration={ + "provider": "znuny", + "protocol": "generic_interface_rest", + }, + effective_hash="configuration-hash", + resource_revision=1, + ambiguity_policy="manual_review", + updated_by="account-1", + ) + self.session.add(configuration) + self.session.flush() + return configuration.id + + def _create_profile(self) -> str: + item = create_profile( + self.session, + principal(), + ServiceDeskProfileCreateRequest( + configuration_id=self.configuration_id, + integration_mode="synchronize", + desired_maturity="synchronize", + source_authority_mode="governed_sync", + default_visibility="restricted", + default_acl_tokens=["group:service-desk-managers"], + routes=ServiceDeskRouteMapping( + update_path="/Ticket/{ticket_id}", + ticket_web_url_template="https://support.example.invalid/ticket/{ticket_id}", + ), + queue_mappings=[ + ServiceDeskQueueMapping( + source_queue="Residents", + target_queue_ref="helpdesk:residents", + visibility="restricted", + acl_tokens=["group:agents"], + ) + ], + dynamic_field_mappings=[ + ServiceDeskDynamicFieldMapping( + source_name="PermitKind", + target_name="permit_kind", + ) + ], + ), + ) + return item.id + + def _discover(self) -> None: + discover_profile( + self.session, + principal(), + profile_id=self.profile_id, + transport=self.transport, + ) + + def _sync(self, raw: list[dict[str, object]], *, key: str, complete: bool = True): + self.transport.batches.append( + ServiceDeskChangeBatch( + changes=tuple(raw), + next_cursor=( + None + if complete + else json.dumps( + {"kind": "full", "offset": len(raw), "fingerprint": "fixture"} + ) + ), + complete=complete, + high_watermark="2026-08-22T10:00:00Z", + live_ids=tuple(str(value["TicketID"]) for value in raw) if complete else None, + evidence={"fixture": True}, + ) + ) + return synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest(idempotency_key=key), + transport=self.transport, + registry=None, + ) + + def test_mapping_replay_cursor_transition_and_search_acl(self) -> None: + self._discover() + run = self._sync([ticket()], key="sync-1") + self.assertEqual({"created": 1}, run.counts) + self.assertEqual("delta", json.loads(run.cursor_after or "{}").get("kind")) + self.assertIn("attachment_content_omitted", {item.code for item in run.diagnostics}) + + objects, _cursor = list_objects(self.session, principal(), profile_id=self.profile_id) + self.assertEqual(1, len(objects)) + item = objects[0] + self.assertEqual("helpdesk:residents", item.mapped_data["target_queue_ref"]) + self.assertEqual("resident", item.mapped_data["dynamic_fields"]["permit_kind"]) + self.assertFalse(item.mapped_data["attachments"][0]["content_retained"]) + self.assertEqual("article", item.mapped_data["articles"][0]["reference"]["object_type"]) + + replay = synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest(idempotency_key="sync-1"), + transport=self.transport, + registry=None, + ) + self.assertEqual(run.id, replay.id) + self.assertEqual(1, len(self.transport.change_calls)) + + source = ExternalServiceDeskSearchSource() + document = source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=SERVICE_DESK_PROVIDER_ID, + resource_type=SERVICE_DESK_RESOURCE_TYPE, + rebuild_id="rebuild-1", + ), + ).documents[0] + authorization = SearchAuthorizationRequest( + reference=document.reference, + source_revision=document.source_revision, + ) + self.assertTrue(source.authorize(self.session, principal(), requests=[authorization])[document.reference.key]) + self.assertFalse( + source.authorize( + self.session, + principal(groups=frozenset({"other"})), + requests=[authorization], + )[document.reference.key] + ) + + def test_paged_full_stays_full_then_switches_to_delta(self) -> None: + self._discover() + first = self._sync([ticket()], key="page-1", complete=False) + self.assertEqual("full", json.loads(first.cursor_after or "{}").get("kind")) + second = self._sync([ticket(ticket_id="43")], key="page-2", complete=True) + self.assertEqual("delta", json.loads(second.cursor_after or "{}").get("kind")) + self.assertTrue(self.transport.change_calls[0]["force_full"]) + self.assertTrue(self.transport.change_calls[1]["force_full"]) + + def test_profile_updates_preserve_provider_acl_until_the_next_sync(self) -> None: + self._discover() + self._sync([ticket(acl=["group:provider-agents"])], key="provider-acl") + profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) + update_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskProfileUpdateRequest( + expected_resource_revision=profile.resource_revision, + default_visibility="tenant", + default_acl_tokens=[], + ), + registry=None, + ) + stored = self.session.scalar( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.profile_id == self.profile_id, + ConnectorServiceDeskObject.external_id == "42", + ) + ) + self.assertEqual("restricted", stored.visibility) + self.assertEqual(["group:provider-agents"], stored.acl_tokens) + self.assertEqual("provider", stored.mapped_data["permission_source"]) + + def test_unchanged_editor_payload_does_not_reset_discovery_or_cursor(self) -> None: + self._discover() + self._sync([ticket()], key="before-noop-save") + profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) + cursor_before = profile.last_sync_cursor + discovered_at = profile.discovered_at + update_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskProfileUpdateRequest( + expected_resource_revision=profile.resource_revision, + integration_mode=profile.integration_mode, + desired_maturity=profile.desired_maturity, + source_authority_mode=profile.source_authority_mode, + default_visibility=profile.default_visibility, + default_acl_tokens=list(profile.default_acl_tokens), + routes=ServiceDeskRouteMapping.model_validate(profile.routes), + queue_mappings=[ + ServiceDeskQueueMapping.model_validate(value) + for value in profile.queue_mappings + ], + dynamic_field_mappings=[ + ServiceDeskDynamicFieldMapping.model_validate(value) + for value in profile.dynamic_field_mappings + ], + ), + registry=None, + ) + self.assertEqual(cursor_before, profile.last_sync_cursor) + self.assertEqual(discovered_at, profile.discovered_at) + + def test_route_or_configuration_changes_require_rediscovery(self) -> None: + self._discover() + self._sync([ticket()], key="before-route-change") + profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) + update_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskProfileUpdateRequest( + expected_resource_revision=profile.resource_revision, + routes=ServiceDeskRouteMapping( + search_path="/GovOPlaN/Ticket/Search", + ticket_path="/Ticket/{ticket_id}", + update_path="/Ticket/{ticket_id}", + ), + ), + registry=None, + ) + self.assertIsNone(profile.discovered_at) + self.assertEqual([], profile.capabilities) + stored = self.session.scalar( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.profile_id == self.profile_id, + ConnectorServiceDeskObject.external_id == "42", + ) + ) + self.assertEqual("deleted", stored.status) + with self.assertRaisesRegex(ServiceDeskConnectorError, "Discover the provider"): + synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest(idempotency_key="route-stale"), + transport=self.transport, + registry=None, + ) + + self._discover() + self._sync([ticket()], key="after-route-rediscovery") + self.assertEqual("active", stored.status) + configuration = self.session.get(ConnectorConfiguration, self.configuration_id) + configuration.resource_revision += 1 + configuration.effective_hash = "configuration-hash-changed" + self.session.commit() + self.assertEqual( + (), + ExternalServiceDeskSearchSource() + .backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=SERVICE_DESK_PROVIDER_ID, + resource_type=SERVICE_DESK_RESOURCE_TYPE, + rebuild_id="stale-configuration", + ), + ) + .documents, + ) + with self.assertRaisesRegex(ServiceDeskConnectorError, "configuration changed"): + synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest(idempotency_key="configuration-stale"), + transport=self.transport, + registry=None, + ) + self._discover() + self.assertEqual("deleted", stored.status) + self.assertIsNone(profile.last_sync_cursor) + + def test_full_sync_reprojects_unchanged_tickets_into_search(self) -> None: + self._discover() + self._sync([ticket()], key="initial-search-projection") + writer = RecordingSearchWriter() + self.transport.batches.append( + ServiceDeskChangeBatch( + changes=(ticket(),), + next_cursor=None, + complete=True, + high_watermark="2026-08-22T10:00:00Z", + live_ids=("42",), + evidence={"fixture": True}, + ) + ) + run = synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest( + idempotency_key="full-search-reprojection", + mode="full", + ), + transport=self.transport, + registry=SearchRegistry(writer), + ) + self.assertEqual({"unchanged": 1}, run.counts) + self.assertEqual(1, len(writer.upserts)) + self.assertIsNone(self.transport.change_calls[-1]["cursor"]) + + def test_delta_cannot_bootstrap_without_a_completed_full_sync(self) -> None: + self._discover() + with self.assertRaisesRegex(ServiceDeskConnectorError, "completed full"): + synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest( + idempotency_key="unsafe-delta-bootstrap", + mode="delta", + ), + transport=self.transport, + registry=None, + ) + self.assertEqual([], self.transport.change_calls) + + def test_link_mode_omits_top_level_attachment_metadata(self) -> None: + self._discover() + profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) + update_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskProfileUpdateRequest( + expected_resource_revision=profile.resource_revision, + integration_mode="link", + desired_maturity="link", + source_authority_mode="linked_reference", + ), + registry=None, + ) + raw = ticket() + raw.pop("Article") + raw.pop("DynamicField") + raw["Attachment"] = [ + { + "AttachmentID": "top-1", + "Filename": "metadata-only.pdf", + "Filesize": 42, + } + ] + run = self._sync([raw], key="link-refresh") + objects, _cursor = list_objects( + self.session, + principal(), + profile_id=self.profile_id, + ) + self.assertEqual([], objects[0].mapped_data["attachments"]) + self.assertIn("link_mode_content_omitted", {item.code for item in run.diagnostics}) + self.assertTrue(self.transport.change_calls[-1]["routes"]["_identity_only"]) + + def test_excluded_queue_removes_projection_and_profile_policy_fails_closed(self) -> None: + self._discover() + self._sync([ticket()], key="sync-active") + profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) + update_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskProfileUpdateRequest( + expected_resource_revision=profile.resource_revision, + queue_mappings=[ + ServiceDeskQueueMapping( + source_queue="Residents", + include=False, + visibility="restricted", + acl_tokens=["group:agents"], + ) + ], + ), + registry=None, + ) + stored = self.session.scalar( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.profile_id == self.profile_id, + ConnectorServiceDeskObject.external_id == "42", + ) + ) + self.assertEqual("deleted", stored.status) + self.assertEqual( + (), + ExternalServiceDeskSearchSource() + .backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=SERVICE_DESK_PROVIDER_ID, + resource_type=SERVICE_DESK_RESOURCE_TYPE, + rebuild_id="excluded-rebuild", + ), + ) + .documents, + ) + self.transport.batches.append( + ServiceDeskChangeBatch( + changes=(ticket(revision="2026-08-22T10:30:00Z"),), + next_cursor=json.dumps( + {"kind": "delta", "changed": "2026-08-22T10:30:00Z", "seen": ["42"]} + ), + complete=True, + high_watermark="2026-08-22T10:30:00Z", + live_ids=None, + evidence={"fixture": True}, + ) + ) + run = synchronize_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskSyncRequest(idempotency_key="sync-excluded"), + transport=self.transport, + registry=None, + ) + self.assertEqual({"ignored": 1}, run.counts) + self.assertEqual("deleted", stored.status) + + with self.assertRaises(ServiceDeskConnectorError): + update_profile( + self.session, + principal(), + profile_id=self.profile_id, + payload=ServiceDeskProfileUpdateRequest( + expected_resource_revision=self.session.get( + ConnectorServiceDeskProfile, self.profile_id + ).resource_revision, + integration_mode="link", + desired_maturity="synchronize", + source_authority_mode="linked_reference", + ), + registry=None, + ) + + def test_governed_update_replay_and_unknown_outcome(self) -> None: + self._discover() + self._sync([ticket()], key="sync-before-update") + payload = ServiceDeskTicketUpdateRequest( + idempotency_key="update-1", + expected_external_revision="2026-08-22T10:00:00Z", + state="pending reminder", + ) + result = update_ticket( + self.session, + principal(), + profile_id=self.profile_id, + external_ticket_id="42", + payload=payload, + transport=self.transport, + registry=None, + durable_recovery=True, + ) + replay = update_ticket( + self.session, + principal(), + profile_id=self.profile_id, + external_ticket_id="42", + payload=payload, + transport=self.transport, + registry=None, + durable_recovery=False, + ) + self.assertTrue(result.accepted) + self.assertEqual(result.run.id, replay.run.id) + self.assertEqual(1, self.transport.update_calls) + recovery = self.session.scalar( + select(RecoveryOperation).where( + RecoveryOperation.resource_type == SERVICE_DESK_RESOURCE_TYPE, + RecoveryOperation.resource_id == "42", + ) + ) + self.assertEqual(RecoveryStatus.SUCCEEDED.value, recovery.status) + + self.transport.update_error = ServiceDeskTransportError( + "provider_unavailable", + "No conclusive provider response.", + retryable=True, + outcome_unknown=True, + ) + with self.assertRaisesRegex(ServiceDeskConnectorError, "outcome is unknown"): + update_ticket( + self.session, + principal(), + profile_id=self.profile_id, + external_ticket_id="42", + payload=ServiceDeskTicketUpdateRequest( + idempotency_key="update-unknown", + expected_external_revision="2026-08-22T11:00:00Z", + priority="4 high", + ), + transport=self.transport, + registry=None, + durable_recovery=False, + ) + unresolved = self.session.scalar( + select(ConnectorServiceDeskSyncRun).where( + ConnectorServiceDeskSyncRun.idempotency_key == "update-unknown" + ) + ) + self.assertEqual("outcome_unknown", unresolved.status) + + def test_tenant_isolation(self) -> None: + with self.assertRaisesRegex(ServiceDeskConnectorError, "not found"): + list_objects(self.session, principal("tenant-2"), profile_id=self.profile_id) + + def test_malformed_continuous_batch_is_atomic_and_evidenced(self) -> None: + self._discover() + malformed = ticket() + malformed.pop("Changed") + with self.assertRaisesRegex(ServiceDeskConnectorError, "change timestamp"): + self._sync([malformed], key="sync-malformed") + self.assertIsNone( + self.session.scalar( + select(ConnectorServiceDeskObject).where( + ConnectorServiceDeskObject.profile_id == self.profile_id + ) + ) + ) + failed = self.session.scalar( + select(ConnectorServiceDeskSyncRun).where( + ConnectorServiceDeskSyncRun.idempotency_key == "sync-malformed" + ) + ) + self.assertEqual("failed", failed.status) + self.assertEqual("change_timestamp_missing", failed.diagnostics[0]["code"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service_desk_transport.py b/tests/test_service_desk_transport.py new file mode 100644 index 0000000..2e035c0 --- /dev/null +++ b/tests/test_service_desk_transport.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import json +from urllib.parse import parse_qs, urlsplit +import unittest +from unittest.mock import patch + +from govoplan_core.security.http_fetch import HttpFetchResponse +from govoplan_connectors.backend.service_desk_transport import ( + HttpServiceDeskTransport, + ServiceDeskTransportError, +) +from govoplan_connectors.backend.service_desk_schemas import ServiceDeskRouteMapping + + +def response(payload: dict[str, object], *, headers: dict[str, str] | None = None): + return HttpFetchResponse( + status=200, + headers={"Content-Type": "application/json", **(headers or {})}, + body=json.dumps(payload).encode(), + ) + + +class ServiceDeskTransportTests(unittest.TestCase): + def setUp(self) -> None: + self.transport = HttpServiceDeskTransport() + self.endpoint = "https://support.example.test/znuny/nph-genericinterface.pl/Webservice/GovOPlaN" + self.routes = { + "search_path": "/Ticket/Search", + "ticket_path": "/Ticket/{ticket_id}", + "search_method": "POST", + "ticket_method": "GET", + } + + def test_governed_routes_reject_embedded_authentication_controls(self) -> None: + for field, value in ( + ("search_path", "/Ticket/Search?Password=secret"), + ( + "ticket_web_url_template", + "https://desk.example.test/ticket/{ticket_id}?SessionID=secret", + ), + ): + with self.subTest(field=field), self.assertRaisesRegex( + ValueError, "authentication controls" + ): + ServiceDeskRouteMapping(**{field: value}) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_discovery_uses_governed_route_header_auth_and_version(self, fetch) -> None: + fetch.return_value = response( + {"TicketID": []}, headers={"X-Znuny-Version": "7.1.4"} + ) + discovery = self.transport.discover( + endpoint_url=self.endpoint, + credential={"user_login": "connector", "password": "secret"}, + routes={**self.routes, "search_filters": {"QueueIDs": [3, 7]}}, + ) + call = fetch.call_args + self.assertEqual("POST", call.kwargs["method"]) + self.assertTrue(call.args[0].endswith("/Ticket/Search")) + self.assertEqual("connector", call.kwargs["headers"]["X-OTRS-Header-UserLogin"]) + self.assertIn( + "X-OTRS-Header-Password", + call.kwargs["redirect_sensitive_headers"], + ) + self.assertNotIn("secret", call.args[0]) + self.assertEqual([3, 7], json.loads(call.kwargs["body"])["QueueIDs"]) + self.assertEqual("znuny", discovery["product"]) + self.assertEqual("synchronize", discovery["maturity"]) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_get_ticket_flags_are_query_parameters_without_secrets(self, fetch) -> None: + fetch.side_effect = ( + response({"TicketID": ["42"]}), + response({"Ticket": [{"TicketID": "42", "Changed": "2026-08-22T10:00:00Z"}]}), + ) + batch = self.transport.changes( + endpoint_url=self.endpoint, + credential={"user_login": "connector", "password": "secret"}, + routes=self.routes, + cursor=None, + limit=100, + force_full=True, + ) + query = parse_qs(urlsplit(fetch.call_args_list[1].args[0]).query) + self.assertEqual(["1"], query["AllArticles"]) + self.assertEqual(["0"], query["GetAttachmentContents"]) + self.assertNotIn("UserLogin", query) + self.assertEqual(1, len(batch.changes)) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_link_identity_reads_do_not_request_articles_attachments_or_dynamic_fields( + self, fetch + ) -> None: + fetch.side_effect = ( + response({"TicketID": ["42"]}), + response( + {"Ticket": [{"TicketID": "42", "Changed": "2026-08-22T10:00:00Z"}]} + ), + ) + self.transport.changes( + endpoint_url=self.endpoint, + credential=None, + routes={**self.routes, "_identity_only": True}, + cursor=None, + limit=100, + force_full=True, + ) + query = parse_qs(urlsplit(fetch.call_args_list[1].args[0]).query) + self.assertEqual(["0"], query["AllArticles"]) + self.assertEqual(["0"], query["Attachments"]) + self.assertEqual(["0"], query["DynamicFields"]) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_paged_full_cursor_preserves_cumulative_high_watermark(self, fetch) -> None: + fetch.side_effect = ( + response({"TicketID": ["41", "42"]}), + response({"Ticket": [{"TicketID": "41", "Changed": "2026-08-22T12:00:00Z"}]}), + response({"TicketID": ["41", "42"]}), + response({"Ticket": [{"TicketID": "42", "Changed": "2026-08-22T10:00:00Z"}]}), + ) + first = self.transport.changes( + endpoint_url=self.endpoint, + credential=None, + routes=self.routes, + cursor=None, + limit=1, + force_full=True, + ) + second = self.transport.changes( + endpoint_url=self.endpoint, + credential=None, + routes=self.routes, + cursor=first.next_cursor, + limit=1, + force_full=True, + ) + self.assertFalse(first.complete) + self.assertTrue(second.complete) + self.assertEqual("2026-08-22T12:00:00Z", second.high_watermark) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_delta_overlaps_and_pages_all_ids_at_one_timestamp(self, fetch) -> None: + shared_revision = "2026-08-22T10:00:00Z" + fetch.side_effect = ( + response({"TicketID": ["41", "42"]}), + response({"Ticket": [{"TicketID": "41", "Changed": shared_revision}]}), + response({"Ticket": [{"TicketID": "42", "Changed": shared_revision}]}), + response({"TicketID": ["41", "42"]}), + response({"Ticket": [{"TicketID": "41", "Changed": shared_revision}]}), + response({"Ticket": [{"TicketID": "42", "Changed": shared_revision}]}), + ) + first = self.transport.changes( + endpoint_url=self.endpoint, + credential=None, + routes=self.routes, + cursor=json.dumps( + {"kind": "delta", "changed": "2026-08-22T09:59:59Z", "seen": []} + ), + limit=1, + force_full=False, + ) + second = self.transport.changes( + endpoint_url=self.endpoint, + credential=None, + routes=self.routes, + cursor=first.next_cursor, + limit=1, + force_full=False, + ) + self.assertEqual(["41"], [item["TicketID"] for item in first.changes]) + self.assertEqual(["42"], [item["TicketID"] for item in second.changes]) + second_search = json.loads(fetch.call_args_list[3].kwargs["body"]) + self.assertEqual( + "2026-08-22T09:59:59Z", + second_search["TicketChangeTimeNewerDate"], + ) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_delta_does_not_suppress_a_seen_ticket_that_changed_again(self, fetch) -> None: + fetch.side_effect = ( + response({"TicketID": ["41"]}), + response( + { + "Ticket": [ + {"TicketID": "41", "Changed": "2026-08-22T10:05:00Z"} + ] + } + ), + ) + batch = self.transport.changes( + endpoint_url=self.endpoint, + credential=None, + routes=self.routes, + cursor=json.dumps( + { + "kind": "delta", + "changed": "2026-08-22T10:00:00Z", + "seen": ["41"], + } + ), + limit=10, + force_full=False, + ) + self.assertEqual(["41"], [item["TicketID"] for item in batch.changes]) + self.assertEqual("2026-08-22T10:05:00Z", batch.high_watermark) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_body_authentication_is_never_put_in_get_url(self, fetch) -> None: + fetch.return_value = response({"TicketID": ["42"]}) + with self.assertRaisesRegex(ServiceDeskTransportError, "cannot be used with a GET"): + self.transport.changes( + endpoint_url=self.endpoint, + credential={ + "auth_mode": "body", + "user_login": "connector", + "password": "secret", + }, + routes={**self.routes, "search_method": "GET"}, + cursor=None, + limit=1, + force_full=True, + ) + fetch.assert_not_called() + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_legacy_body_authentication_is_post_only_and_not_duplicated(self, fetch) -> None: + fetch.return_value = response({"TicketID": []}) + self.transport.discover( + endpoint_url=self.endpoint, + credential={ + "auth_mode": "body", + "user_login": "connector", + "password": "secret", + }, + routes=self.routes, + ) + call = fetch.call_args + payload = json.loads(call.kwargs["body"]) + self.assertEqual("connector", payload["UserLogin"]) + self.assertEqual("secret", payload["Password"]) + self.assertNotIn("X-OTRS-Header-UserLogin", call.kwargs["headers"]) + self.assertNotIn("secret", call.args[0]) + + @patch("govoplan_connectors.backend.service_desk_transport.fetch_http") + def test_update_requires_revision_and_requested_field_verification(self, fetch) -> None: + fetch.side_effect = ( + response( + { + "Ticket": [ + { + "TicketID": "42", + "State": "open", + "Changed": "2026-08-22T10:00:00Z", + } + ] + } + ), + response({"Success": 1}), + response( + { + "Ticket": [ + { + "TicketID": "42", + "State": "open", + "Changed": "2026-08-22T10:05:00Z", + } + ] + } + ), + ) + with self.assertRaises(ServiceDeskTransportError) as raised: + self.transport.update_ticket( + endpoint_url=self.endpoint, + credential=None, + routes={**self.routes, "update_path": "/Ticket/{ticket_id}"}, + ticket_id="42", + expected_revision="2026-08-22T10:00:00Z", + changes={"State": "pending reminder"}, + ) + self.assertTrue(raised.exception.outcome_unknown) + self.assertEqual("update_verification_failed", raised.exception.code) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index f9eedf1..7aa9bb4 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/connectors-webui", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/api/externalServiceDesk.ts b/webui/src/api/externalServiceDesk.ts new file mode 100644 index 0000000..b0a673c --- /dev/null +++ b/webui/src/api/externalServiceDesk.ts @@ -0,0 +1,208 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + +export type ServiceDeskDiagnostic = { + severity: "info" | "warning" | "error"; + code: string; + message: string; + object_ref?: string | null; + field?: string | null; + retryable: boolean; + details: Record; +}; + +export type ServiceDeskRouteMapping = { + search_path: string; + ticket_path: string; + update_path?: string | null; + search_method: "GET" | "POST"; + ticket_method: "GET" | "POST"; + update_method: "PATCH" | "POST" | "PUT"; + ticket_web_url_template?: string | null; + search_filters: Record; +}; + +export type ServiceDeskQueueMapping = { + source_queue: string; + target_queue_ref?: string | null; + include: boolean; + visibility: "tenant" | "restricted"; + acl_tokens: string[]; +}; + +export type ServiceDeskDynamicFieldMapping = { + source_name: string; + target_name?: string | null; + include: boolean; + value_type: "string" | "number" | "boolean" | "date" | "json"; +}; + +export type ServiceDeskProfile = { + id: string; + tenant_id: string; + configuration_id: string; + status: "active" | "paused"; + integration_mode: "link" | "import" | "synchronize"; + product: string; + product_version?: string | null; + desired_maturity: "discover" | "link" | "search" | "read" | "publish" | "synchronize"; + discovered_maturity: string; + source_authority_mode: "external_authoritative" | "external_mirror" | "governed_sync" | "linked_reference"; + default_visibility: "tenant" | "restricted"; + default_acl_tokens: string[]; + routes: ServiceDeskRouteMapping; + queue_mappings: ServiceDeskQueueMapping[]; + dynamic_field_mappings: ServiceDeskDynamicFieldMapping[]; + capabilities: string[]; + discovery_revision?: string | null; + discovered_configuration_revision?: number | null; + discovered_configuration_hash?: string | null; + health_status: string; + health_details: Record; + discovered_at?: string | null; + last_sync_cursor?: string | null; + last_high_watermark?: string | null; + resource_revision: number; + credential_reference_present: boolean; + endpoint_configured: boolean; + created_at: string; + updated_at: string; +}; + +export type ServiceDeskObject = { + id: string; + profile_id: string; + object_type: string; + external_id: string; + external_ticket_number?: string | null; + title: string; + canonical_url?: string | null; + status: string; + source_revision: string; + visibility: string; + acl_tokens: string[]; + mapped_data: Record; + provenance: Record; + source_updated_at?: string | null; + observed_at: string; + resource_revision: number; +}; + +export type ServiceDeskRun = { + id: string; + profile_id: string; + mode: string; + idempotency_key: string; + status: string; + cursor_before?: string | null; + cursor_after?: string | null; + high_watermark?: string | null; + counts: Record; + effects: Array>; + diagnostics: ServiceDeskDiagnostic[]; + provenance: Record; + started_at: string; + finished_at?: string | null; + created_at: string; +}; + +export type ServiceDeskDiscovery = { + profile: ServiceDeskProfile; + product: string; + product_version?: string | null; + api_family: string; + capabilities: string[]; + maturity: string; + health_status: string; + diagnostics: ServiceDeskDiagnostic[]; + revision: string; +}; + +export type ServiceDeskTicketUpdateResult = { + run: ServiceDeskRun; + object: ServiceDeskObject; + accepted: boolean; + outcome_unknown: boolean; +}; + +const ROOT = "/api/v1/connectors/service-desk"; + +export async function listServiceDeskProfiles(settings: ApiSettings): Promise { + const response = await apiFetch<{ items: ServiceDeskProfile[] }>(settings, `${ROOT}/profiles`); + return response.items; +} + +export function createServiceDeskProfile( + settings: ApiSettings, + payload: Record +): Promise { + return apiFetch(settings, `${ROOT}/profiles`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateServiceDeskProfile( + settings: ApiSettings, + profileId: string, + payload: Record +): Promise { + return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}`, { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export function discoverServiceDeskProfile( + settings: ApiSettings, + profileId: string +): Promise { + return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}/discover`, { + method: "POST" + }); +} + +export function synchronizeServiceDeskProfile( + settings: ApiSettings, + profileId: string, + payload: Record +): Promise { + return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}/sync`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function listServiceDeskObjects( + settings: ApiSettings, + profileId: string +): Promise { + const response = await apiFetch<{ items: ServiceDeskObject[] }>( + settings, + apiPath(`${ROOT}/profiles/${encodeURIComponent(profileId)}/objects`, { limit: 100 }) + ); + return response.items; +} + +export async function listServiceDeskRuns( + settings: ApiSettings, + profileId?: string +): Promise { + const response = await apiFetch<{ items: ServiceDeskRun[] }>( + settings, + apiPath(`${ROOT}/runs`, { profile_id: profileId || undefined, limit: 100 }) + ); + return response.items; +} + +export function updateServiceDeskTicket( + settings: ApiSettings, + profileId: string, + externalTicketId: string, + payload: Record +): Promise { + return apiFetch( + settings, + `${ROOT}/profiles/${encodeURIComponent(profileId)}/tickets/${encodeURIComponent(externalTicketId)}/update`, + { method: "POST", body: JSON.stringify(payload) } + ); +} diff --git a/webui/src/features/ExternalServiceDeskPage.tsx b/webui/src/features/ExternalServiceDeskPage.tsx new file mode 100644 index 0000000..3767a01 --- /dev/null +++ b/webui/src/features/ExternalServiceDeskPage.tsx @@ -0,0 +1,573 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AdminPageLayout, + Button, + Card, + Dialog, + FilterBar, + FormField, + FormGrid, + MetricCard, + MetricGrid, + PageActionBar, + SelectionList, + SelectionListItem, + SelectionListItemContent, + StatePanel, + StatusBadge, + WorkspaceLayout, + formatDateTime, + hasScope, + useUnsavedChanges, + useUnsavedDraftGuard, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + createServiceDeskProfile, + discoverServiceDeskProfile, + listServiceDeskObjects, + listServiceDeskProfiles, + listServiceDeskRuns, + synchronizeServiceDeskProfile, + updateServiceDeskProfile, + updateServiceDeskTicket, + type ServiceDeskObject, + type ServiceDeskProfile, + type ServiceDeskRun +} from "../api/externalServiceDesk"; + +type Props = { settings: ApiSettings; auth: AuthInfo }; + +type ProfileDraft = { + status: "active" | "paused"; + integration_mode: ServiceDeskProfile["integration_mode"]; + desired_maturity: ServiceDeskProfile["desired_maturity"]; + source_authority_mode: ServiceDeskProfile["source_authority_mode"]; + default_visibility: ServiceDeskProfile["default_visibility"]; + default_acl_tokens: string; + routes: string; + queue_mappings: string; + dynamic_field_mappings: string; +}; + +const DEFAULT_ROUTES = { + search_path: "/Ticket/Search", + ticket_path: "/Ticket/{ticket_id}", + update_path: null, + search_method: "POST", + ticket_method: "GET", + update_method: "PATCH", + ticket_web_url_template: null, + search_filters: {} +}; + +const EMPTY_DRAFT: ProfileDraft = { + status: "active", + integration_mode: "synchronize", + desired_maturity: "synchronize", + source_authority_mode: "external_authoritative", + default_visibility: "restricted", + default_acl_tokens: "scope:connectors:service_desk:read", + routes: JSON.stringify(DEFAULT_ROUTES, null, 2), + queue_mappings: JSON.stringify([], null, 2), + dynamic_field_mappings: JSON.stringify([], null, 2) +}; + +export default function ExternalServiceDeskPage({ settings, auth }: Props) { + const [profiles, setProfiles] = useState([]); + const [objects, setObjects] = useState([]); + const [runs, setRuns] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [savedKey, setSavedKey] = useState(""); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [configurationId, setConfigurationId] = useState(""); + const [newDraft, setNewDraft] = useState(EMPTY_DRAFT); + const [updateOpen, setUpdateOpen] = useState(false); + const [updateObjectId, setUpdateObjectId] = useState(""); + const [updateTitle, setUpdateTitle] = useState(""); + const [updateQueue, setUpdateQueue] = useState(""); + const [updateState, setUpdateState] = useState(""); + const [updatePriority, setUpdatePriority] = useState(""); + const [updateOwner, setUpdateOwner] = useState(""); + const [updateResponsible, setUpdateResponsible] = useState(""); + const [updateDynamicFields, setUpdateDynamicFields] = useState("{}"); + const { requestDiscard } = useUnsavedChanges(); + + const selected = profiles.find((item) => item.id === selectedId) ?? null; + const selectedObject = objects.find((item) => item.id === updateObjectId) ?? null; + const canAdmin = hasScope(auth, "connectors:service_desk:admin"); + const canSync = hasScope(auth, "connectors:service_desk:sync"); + const canUpdate = hasScope(auth, "connectors:service_desk:update"); + const dirty = Boolean(selected && draftKey(draft) !== savedKey); + + const applyProfile = useCallback((profile: ServiceDeskProfile | null) => { + const next = profile ? draftFromProfile(profile) : EMPTY_DRAFT; + setDraft(next); + setSavedKey(profile ? draftKey(next) : ""); + }, []); + + const reload = useCallback(async (preferredId?: string) => { + setLoading(true); + setError(""); + try { + const nextProfiles = await listServiceDeskProfiles(settings); + const nextId = preferredId && nextProfiles.some((item) => item.id === preferredId) + ? preferredId + : nextProfiles.some((item) => item.id === selectedId) + ? selectedId + : nextProfiles[0]?.id ?? ""; + const [nextObjects, nextRuns] = nextId + ? await Promise.all([ + listServiceDeskObjects(settings, nextId), + listServiceDeskRuns(settings, nextId) + ]) + : [[], []]; + setProfiles(nextProfiles); + setSelectedId(nextId); + setObjects(nextObjects); + setRuns(nextRuns); + applyProfile(nextProfiles.find((item) => item.id === nextId) ?? null); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setLoading(false); + } + }, [applyProfile, selectedId, settings]); + + useEffect(() => { + void reload(); + }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + + const save = async (): Promise => { + if (!selected || !canAdmin) return false; + setBusy(true); + setError(""); + try { + const updated = await updateServiceDeskProfile(settings, selected.id, { + expected_resource_revision: selected.resource_revision, + status: draft.status, + integration_mode: draft.integration_mode, + desired_maturity: draft.desired_maturity, + source_authority_mode: draft.source_authority_mode, + default_visibility: draft.default_visibility, + default_acl_tokens: lines(draft.default_acl_tokens), + routes: parseObject(draft.routes, "Routes"), + queue_mappings: parseArray(draft.queue_mappings, "Queue mappings"), + dynamic_field_mappings: parseArray(draft.dynamic_field_mappings, "Dynamic-field mappings") + }); + setSuccess("Service-desk profile saved; queue ACLs and Search projections were refreshed."); + await reload(updated.id); + return true; + } catch (caught) { + setError(errorMessage(caught)); + return false; + } finally { + setBusy(false); + } + }; + + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: () => applyProfile(selected), + title: "Unsaved service-desk profile changes", + message: "Save or discard the profile changes before continuing." + }); + + const selectProfile = (profile: ServiceDeskProfile) => { + if (profile.id === selectedId) return; + requestDiscard(() => { + setSelectedId(profile.id); + applyProfile(profile); + setObjects([]); + setRuns([]); + void Promise.all([ + listServiceDeskObjects(settings, profile.id), + listServiceDeskRuns(settings, profile.id) + ]).then(([nextObjects, nextRuns]) => { + setObjects(nextObjects); + setRuns(nextRuns); + }).catch((caught) => setError(errorMessage(caught))); + }); + }; + + const createProfile = async () => { + if (!configurationId.trim()) return; + setBusy(true); + setError(""); + try { + const created = await createServiceDeskProfile(settings, { + configuration_id: configurationId.trim(), + integration_mode: newDraft.integration_mode, + desired_maturity: newDraft.desired_maturity, + source_authority_mode: newDraft.source_authority_mode, + default_visibility: newDraft.default_visibility, + default_acl_tokens: lines(newDraft.default_acl_tokens), + routes: parseObject(newDraft.routes, "Routes"), + queue_mappings: parseArray(newDraft.queue_mappings, "Queue mappings"), + dynamic_field_mappings: parseArray(newDraft.dynamic_field_mappings, "Dynamic-field mappings") + }); + setCreateOpen(false); + setConfigurationId(""); + setNewDraft(EMPTY_DRAFT); + setSuccess("Service-desk profile created. Run discovery before synchronization."); + await reload(created.id); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const discover = async () => { + if (!selected || dirty) return; + setBusy(true); + setError(""); + try { + const result = await discoverServiceDeskProfile(settings, selected.id); + setSuccess(`Discovered ${result.product} ${result.product_version ?? ""} at ${result.maturity} maturity with ${result.diagnostics.length} diagnostics.`); + await reload(selected.id); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const sync = async (mode: "auto" | "full") => { + if (!selected || dirty) return; + setBusy(true); + setError(""); + try { + const run = await synchronizeServiceDeskProfile(settings, selected.id, { + idempotency_key: `service-desk-${mode}-${crypto.randomUUID()}`, + mode, + limit: 100 + }); + setSuccess(`${mode === "full" ? "Full synchronization" : "Next synchronization page"} completed with ${effectTotal(run)} effects.`); + await reload(selected.id); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const openTicketUpdate = (item: ServiceDeskObject) => { + setUpdateObjectId(item.id); + setUpdateTitle(""); + setUpdateQueue(""); + setUpdateState(""); + setUpdatePriority(""); + setUpdateOwner(""); + setUpdateResponsible(""); + setUpdateDynamicFields("{}"); + setUpdateOpen(true); + }; + + const submitTicketUpdate = async () => { + if (!selected || !selectedObject) return; + const dynamicFields = parseObject(updateDynamicFields, "Dynamic fields"); + const changes = compact({ + title: updateTitle, + queue: updateQueue, + state: updateState, + priority: updatePriority, + owner: updateOwner, + responsible: updateResponsible + }); + if (!Object.keys(changes).length && !Object.keys(dynamicFields).length) { + setError("Enter at least one governed ticket change."); + return; + } + setBusy(true); + setError(""); + try { + const result = await updateServiceDeskTicket( + settings, + selected.id, + selectedObject.external_id, + { + idempotency_key: `service-desk-update-${crypto.randomUUID()}`, + expected_external_revision: selectedObject.source_revision, + ...changes, + dynamic_fields: dynamicFields + } + ); + setUpdateOpen(false); + setSuccess(result.outcome_unknown + ? "Update outcome is unknown. Inspect the provider revision before retrying." + : "Provider accepted the revision-checked ticket update and durable evidence was recorded."); + await reload(selected.id); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const visibleProfiles = useMemo(() => { + const needle = search.trim().toLocaleLowerCase(); + return profiles.filter((item) => !needle || + `${item.product} ${item.product_version ?? ""} ${item.integration_mode} ${item.health_status} ${item.configuration_id}` + .toLocaleLowerCase().includes(needle)); + }, [profiles, search]); + + const actionBar = void reload(selectedId), loading }} + primaryActions={<> + + + + + } + discardAction={{ + label: "Discard changes", + disabled: !selected, + onClick: () => applyProfile(selected) + }} + saveAction={{ + label: "Save", + disabled: !selected || !canAdmin || busy, + disabledReason: !canAdmin ? "Service-desk administration permission is required." : undefined, + onClick: () => void save() + }} + />; + + return + + + item.status !== "deleted").length} /> + !["healthy", "unknown"].includes(item.health_status)).length} tone="warning" /> + ["failed", "outcome_unknown"].includes(item.status)).length} tone="warning" /> + + + + + setSearch(event.target.value)} placeholder="Search profiles" aria-label="Search service-desk profiles" /> + + + {visibleProfiles.map((profile) => selectProfile(profile)}> + + + )} + {!visibleProfiles.length ? : null} + + } + > + {!selected ? :
+ +
+ + + Discovered maturity: {selected.discovered_maturity} + r{selected.resource_revision} +
+ + + + + + + + + + + + + + + + + +