diff --git a/landing-zone-configurator/app/apps/api/src/app.ts b/landing-zone-configurator/app/apps/api/src/app.ts index 727e080..def1a20 100644 --- a/landing-zone-configurator/app/apps/api/src/app.ts +++ b/landing-zone-configurator/app/apps/api/src/app.ts @@ -53,7 +53,7 @@ export function buildApp( developmentLogin?: DevelopmentLogin; assistant?: Pick< AssistantConversations, - "get" | "send" | "accept" | "remove" + "get" | "send" | "accept" | "discard" | "remove" > & Partial< Pick< diff --git a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts index 28f79ba..53f91a4 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts @@ -402,12 +402,12 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/service.ts", sha256: - "123d79e0bb930a7817fc0557a609587050f56104e6a62c5aa499f1beec3b856f", + "f9560f36911f5735ebe69ca7f55f642fda0c78d8d005b975cee8efd032f78c57", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "4f13675917d05b0e57de3ba3cc01a02aa50775608b0004ac8126e429debe7f3c", + "1a374ed816dfcaa67a43bd27ca6e36ed2baceabf4bc904fede3e8b373d32680b", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1269,9 +1269,9 @@ export const assistantKnowledgeTopics = { ], source: "landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.md", - text: '# Platform Kubernetes, KMS and Bastion\n\n## Purpose\n\nCreate shared STACKIT Kubernetes infrastructure with deliberate networking,\nnode pools and supported optional DNS, monitoring, encrypted storage and debug\naccess. These are separate choices, not implicit parts of an SNA request.\n\n## Accelerator Contract\n\nNative platform_kubernetes is a keyed map. Entries define region, cluster\n(name/version/node_pools/maintenance), network, dns, observability,\nencrypted_volumes, debug_bastion and role_assignments. Network settings include\nsna_enabled, network_area_key and prefix; root resolves native area references\nor an explicitly bound external area. Regional module calls select eu01/eu02.\nKMS-backed storage-class configuration differs from enabling all-volume\nencryption. Debug bastion is a separate VM/SSH access feature. Protected\nplatform_kubernetes_kube_config_override is not normal chat configuration.\n\n## Dependencies\n\nSNA/region for corporate mode, governance/platform ownership and credential\nfoundations. DNS and observability are independent nested options. KMS needs\nrights and compatible storage; namespaces consume the supported cluster/provider\npath. Public mode is not private reachability by assumption.\n\n## Dialogue\n\nAsk about workload scale, private/public connectivity, node-pool/system workload\nroles, availability, maintenance, storage encryption and operational access.\nUse live versions/machines/zones/volumes, not static default SKUs. Never create\na bastion, expose SSH to all networks or enable demos merely to "test SNA".\n\n## Examples\n\nSynthetic opt-out: platform_kubernetes = {}. An enabled entry needs confirmed\ncluster/node-pool settings; a /24 network prefix or key default is not enough\nto promise 100 workloads, cluster quotas or per-namespace isolation.\n\n## Limits\n\nNative Editor proposals support reviewed public cluster/KMS/bastion settings,\nnot credentials or deployment. Validate region,\nprefix bounds, node min/max, system pools and area references. Template defaults\nmay contain region-specific zones. In-cluster connectivity, provider access,\nKMS use and node health need separate real execution qualification.\n\n## Credentials\n\nProvider/KMS rights, Kubernetes access and bastion SSH are distinct secure\nbindings. Never request kubeconfig contents, private SSH keys or KMS secret\nmaterial; externally supplied overrides must work with the native contract.\n\n## Sources\n\nRoot calls, cluster/bastion variables, providers and shared validation define\nthe reviewed scope. Re-review DNS/region/provider and encryption changes before\ngeneration; Markdown does not certify private-cluster execution.', + text: '# Platform Kubernetes, KMS and Bastion\n\n## Purpose\n\nConfigure shared STACKIT Kubernetes with deliberate networking and node pools.\nDNS, monitoring, encrypted storage and debug access are separate choices, not\nimplicit parts of an SNA request.\n\n## Accelerator Contract\n\nplatform_kubernetes is a keyed map. Each entry has region and cluster with\nname, kubernetes_version_min, node_pools and maintenance. Pools define name,\nmachine_type, minimum/maximum, availability_zones and optional system-component,\nvolume, OS and label settings. network uses sna_enabled, network_area_key,\nsna_network_area_id, firewall_next_hop_ip and sna_network_prefix_length; root\nresolves native area keys or an explicitly bound external area for eu01/eu02.\ndns, observability, encrypted_volumes, debug_bastion and role_assignments are\nseparate options. KMS-backed storage classes do not encrypt every volume.\nDebug bastion is a separate VM/SSH feature. Protected\nplatform_kubernetes_kube_config_override is not public chat configuration.\n\n## Dependencies\n\nSNA/region for corporate mode, governance/platform ownership and credential\nfoundations. DNS and observability are independent nested options. KMS needs\nrights and compatible storage; namespaces consume the supported cluster/provider\npath. Public mode is not private reachability by assumption.\n\n## Dialogue\n\nAsk about scale, connectivity, system/workload pools, availability, maintenance,\nencryption and operational access.\nUse live versions/machines/zones/volumes, not static default SKUs. Never create\na bastion, expose SSH to all networks or enable demos merely to "test SNA".\n\n## Examples\n\nSynthetic opt-out: platform_kubernetes = {}. Enabled clusters need confirmed\ncluster/pool settings; a /24 or key default guarantees neither free addresses,\nworkload capacity, quotas nor namespace isolation.\n\n## Limits\n\nThe Editor supports reviewed public cluster/KMS/bastion drafts, not credentials\nor deployment. Validate region, prefixes, pool minimum/maximum, system pools\nand area references. Defaults may contain regional zones. Connectivity, provider\naccess, KMS and node health need separate execution qualification.\n\n## Credentials\n\nProvider/KMS rights, Kubernetes access and bastion SSH need distinct secure\nbindings. Never request kubeconfig, private SSH keys or KMS secrets; external\noverrides must match the native contract.\n\n## Sources\n\nRoot calls, cluster/bastion variables, providers and validation define this\nscope. Review DNS, region, provider and encryption changes before generation.\nMarkdown does not certify private-cluster execution.', sha256: - "8aa0a773059fb47a1f9fa46524fe3aeae2b4f94c23d8f1c46cedd4820f2f7557", + "90669bb4c25a91714c74caca6b015e69810d878d55f040d266e09a5b4582e3f4", }, { id: "project-templates", @@ -1560,7 +1560,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/service.ts", sha256: - "123d79e0bb930a7817fc0557a609587050f56104e6a62c5aa499f1beec3b856f", + "f9560f36911f5735ebe69ca7f55f642fda0c78d8d005b975cee8efd032f78c57", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts", @@ -1575,17 +1575,17 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/packages/contracts/src/assistant.ts", sha256: - "ae6acbabc570c72a1b2cc14bf05c820747d69d082d058689e1a7624cad2b0ba4", + "da3dd860c20b28d68b68a4312a47802205dbd957595ccd03f6c8413786421e91", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "4f13675917d05b0e57de3ba3cc01a02aa50775608b0004ac8126e429debe7f3c", + "1a374ed816dfcaa67a43bd27ca6e36ed2baceabf4bc904fede3e8b373d32680b", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", sha256: - "906dc58635bc8d738e252777c7524caf59631fbff4f4a5d8f470aa85c82630b9", + "0a87435fcb6ae19aa08fb93716895205261acb19f147b28e1ceba7f1f9bff7c4", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/topic-fields.ts", diff --git a/landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts b/landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts index 993723a..c186e88 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts @@ -54,6 +54,8 @@ export const assistantFirewallPolicyLimit = "Der Assistant kann aktuell keine Firewall-Regeln, NAT, Aliase oder Routen definieren oder als Vorschlag übernehmen. Die geführte Policy-Bearbeitung in Editor und Chat ist nicht Teil des MVP (GitHub-Issue #101). Im MVP pflegt der Firewall-Admin die Policy nach Bereitstellung manuell in OPNsense."; const fieldOverviewNotes: Record = { + "platform-kubernetes": + "platform_kubernetes ist eine Map mit stabilen Schlüsseln. Jeder Eintrag enthält region und cluster mit name, optionalem kubernetes_version_min, node_pools und maintenance. Node-Pools haben name, machine_type, minimum, maximum und availability_zones; optional sind allow_system_components, volume_size, volume_type, os_name und labels. minimum und maximum begrenzen die Pool-Größe; System-Komponenten benötigen geeignete Pools. Versionen, Maschinen und Zonen anhand aktueller Produktdaten prüfen, keine statische Verfügbarkeitszusage. network enthält sna_enabled, network_area_key, sna_network_area_id, firewall_next_hop_ip und sna_network_prefix_length. SNA-Zuordnung und regionale Area-Referenzen müssen passen; ein Präfix allein garantiert weder freie Adressen noch Workload-Kapazität. dns, observability, encrypted_volumes, debug_bastion und role_assignments sind getrennte Optionen. KMS-gestützte Storage-Classes bedeuten nicht automatisch Verschlüsselung aller Volumes. Die Debug-Bastion ist eine separate VM mit SSH-Zugang, kein impliziter Bestandteil eines SNA-Auftrags. platform_kubernetes={} deaktiviert diese Cluster. platform_kubernetes_kube_config_override ist ein separater geschützter Eingang, keine öffentliche Chat-Einstellung: niemals Kubeconfig-Inhalte, private SSH-Schlüssel oder KMS-Geheimnisse im Chat anfordern oder eintragen. Cluster-/Provider-Erreichbarkeit, Kubernetes-Zugang, KMS-Rechte und Node-Gesundheit brauchen getrennte Ausführungsprüfung. Feldübersicht ist kein Einrichtungsvorschlag: nichts geändert oder gespeichert; Plan und ausdrücklich freigegebener Apply bleiben separat.", "dns-zones": "connectivity.dns_zones und regionale connectivity_regions-Einträge enthalten die Zonen mit stabilen Schlüsseln und den Feldern dns_name, name, description, default_ttl, type, contact_email, acl und network_area_key. SNA default_nameservers legt Resolver fest, keine autoritative Zone. type hat den nativen Default primary. Primary verwaltet die führenden DNS-Einträge bei STACKIT und wird für eine neue dort verwaltete Firmenzone empfohlen. Secondary repliziert eine Kopie vom externen Primary. Beide sind autoritativ; primary/secondary bezeichnet nicht public/private oder die Sichtbarkeit. Der native Accelerator bildet die benötigte Konfiguration externer Primary-Server für Secondary nicht ab; daher kein vollständiges Secondary-Setup und keine stille Umwandlung bestehender Secondary-Zonen. default_ttl hat den nativen Default 3600 Sekunden; 60 bis 99999999 sind im Editor zulässig. Kürzere TTL erleichtert Änderungen, verlängerte TTL hält Cache-Einträge länger. Kontakt und SNA-Zuordnung aus bestätigten Angaben erhalten; kein erfundener Kontakt. ACL wird im gepinnten Provider nicht durchgesetzt und ist kein Zugriffsschutz. Leere Zonen-Map bedeutet keine Zone. Eine freie Subdomain ist keine kostenlose DNS-Service-Zusage. Verfügbarkeit, Delegation, Propagation und Erreichbarkeit bleiben separat zu prüfen. Nur Feld- und Rollenübersicht: nichts geändert, gespeichert oder ausgeführt.", "devops-git": diff --git a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts index 60de519..3281473 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts @@ -131,6 +131,51 @@ export type AssistantCatalogueContext = { }[]; }; +// Literal field-labelled lists are user input, not values for the model to invent +// or expand. Resolve both recipients independently and keep catalogue checks later. +function applicationPermissionLists(message: string) { + const fields = ["permissions", "automationPermissions"] as const; + const sources = fields.map((field) => { + const head = String.raw`(?:^|[^A-Za-z0-9_])["\x27\x60]?${field}["\x27\x60]?\s*(?::|=)?\s*\[`; + return { + field, + headers: [...message.matchAll(new RegExp(head, "g"))], + arrays: [ + ...message.matchAll(new RegExp(head + String.raw`([^\]]*)\]`, "g")), + ], + }; + }); + if (sources.some((source) => source.headers.length === 0)) return false; + const result: Record<"permissions" | "automationPermissions", string[]> = { + permissions: [], + automationPermissions: [], + }; + for (const { field, headers, arrays } of sources) { + if (headers.length !== 1 || arrays.length !== 1) return null; + const body = arrays[0]![1]!.trim(); + let values: unknown; + try { + values = JSON.parse(`[${body}]`); + } catch { + values = body + ? body + .split(",") + .map((value) => value.trim().replace(/^(["'`])(.+)\1$/, "$2")) + : []; + } + if ( + !Array.isArray(values) || + values.length > 2000 || + !values.every( + (value) => applicationPermissionName.safeParse(value).success, + ) + ) + return null; + result[field] = values as string[]; + } + return result; +} + function modelCatalogueContext( context: AssistantCatalogueContext, ): AssistantCatalogueContext { @@ -708,6 +753,113 @@ export class AssistantModelServing { } return null; })(); + const applicationAccessScope = z.strictObject({ + key: z + .enum([...existingTemplateKeys, "__select_by_kind__"]) + .describe( + "Use the exact existing key only when the user identifies that template. For a kind-only Public/Corporate request use __select_by_kind__, never guess among matching templates.", + ), + }); + const permissionListInput = applicationPermissionLists(message); + const sourcePermissionLists = permissionListInput || null; + const customApplicationAccessIntent = applicationAccessScope.extend({ + choice: z + .literal("custom-granular") + .describe( + "Only USER-specified explicit permission lists, not a preset or service addition.", + ), + initialOwnerEmail: z.string().max(254).default(""), + ...(sourcePermissionLists + ? { + listOperation: z + .enum(["replace", "append", "clarify"]) + .describe( + "CURRENT USER intent for BOTH field-labelled lists: replace for exact desired complete lists; append for adding entries while keeping each recipient's existing rights and the central owner. clarify when recipients need different operations or the operation is ambiguous. Never turn an addition into replacement. No permission IDs in the response.", + ), + } + : { + permissions: z + .array(z.string().min(1).max(256)) + .max(2000) + .default([]), + automationPermissions: z + .array(z.string().min(1).max(256)) + .max(2000) + .default([]), + }), + }); + const allApplicationAccessIntents = z.union([ + applicationAccessScope.extend({ + choice: z + .literal("owner") + .describe( + "Only explicitly requested full project Owner rights for requester and Application-service-account; no granular fields. Naming a central initial owner is NOT consent to give the requester Owner rights.", + ), + }), + applicationAccessScope.extend({ + choice: z + .enum(["vm-operations-v1", "application-workloads-v1"]) + .describe( + "Explicit VM-only preset, or recommended Application workload preset for VMs, Object Storage, Observability and Secrets Manager. A separate central initialOwnerEmail does not select full project Owner rights for the requester. The server resolves every preset permission; NEVER generate permission IDs here.", + ), + initialOwnerEmail: z.string().max(254).default(""), + }), + applicationAccessScope.extend({ + choice: z + .literal("add-service-permissions") + .describe( + "Add official service permissions to an existing granular profile; the server resolves all permission IDs. Never generate full lists or change the central owner.", + ), + templateKind: z + .enum(["public", "corporate", "unspecified"]) + .default("unspecified") + .describe( + "Public/Corporate only when the current user identifies the kind rather than a specific template; otherwise unspecified.", + ), + templateEvidence: z + .string() + .max(1000) + .default("") + .describe( + "Exact current USER quote identifying Public or Corporate. If only a kind is named, use key=__select_by_kind__ and this quote; the server resolves exactly one matching existing template or asks which. Never guess a key when several match.", + ), + services: z + .array(z.enum(permissionServices as [string, ...string[]])) + .max(60) + .default([]) + .describe( + "Official permission service prefixes explicitly requested for addition. Kubernetes cluster management is ske; do not add IAM or unrelated services. This is project IAM, not Kubernetes namespace RBAC.", + ), + permissionTarget: z + .enum(["requester", "automation", "both"]) + .default("both") + .describe( + "Who should receive the requested rights? requester = the person ordering the Landing Zone; automation = the created Application-Service-Account; both ONLY if both are intended or no recipient is mentioned. A request that someone gets rights when ordering (German: bei der Bestellung ... Rechte bekommt) explicitly means requester, NOT both. The word Application Landing Zone is not a request for automation permissions. Preserve the other recipient unchanged.", + ), + serviceEvidenceSource: z + .enum([ + "current", + ...(pendingPermissionSelection + ? [pendingPermissionSelection.source] + : []), + ]) + .default("current") + .describe( + "Use current normally. The supplied pendingPermissionSelection source may be used ONLY when the current user selects exactly that offered template, continuing the still-open original service-rights request; keep its exact services and permissionTarget. Never use arbitrary history.", + ), + serviceEvidence: z + .string() + .max(1000) + .default("") + .describe( + "Exact nonempty quote from the selected USER source requesting service permission addition. Current normally; only the supplied still-open pending selection may use its controlled history source. Never assistant statements or a catalogue.", + ), + }), + customApplicationAccessIntent, + ]); + const applicationAccessIntent = sourcePermissionLists + ? customApplicationAccessIntent + : allApplicationAccessIntents; const intentSchema = z.strictObject({ applicationAccessLanguage: z .enum(["de", "en"]) @@ -715,78 +867,7 @@ export class AssistantModelServing { .describe( "Language of the current user or latest natural-language user turn.", ), - applicationAccess: z - .strictObject({ - key: z - .enum([...existingTemplateKeys, "__select_by_kind__"]) - .describe( - "Use the exact existing key only when the user identifies that template. For a kind-only Public/Corporate request use __select_by_kind__, never guess among matching templates.", - ), - templateKind: z - .enum(["public", "corporate", "unspecified"]) - .default("unspecified") - .describe( - "Public/Corporate only when the current user identifies the kind rather than a specific template; otherwise unspecified.", - ), - templateEvidence: z - .string() - .max(1000) - .default("") - .describe( - "Exact current USER quote identifying Public or Corporate. If only a kind is named, use key=__select_by_kind__ and this quote; the server resolves exactly one matching existing template or asks which. Never guess a key when several match.", - ), - choice: z - .enum([ - "owner", - "vm-operations-v1", - "application-workloads-v1", - "custom-granular", - "add-service-permissions", - ]) - .describe( - "USER's requested requester/Application-service-account access, not the separate initial owner: owner ONLY for full owner rights to requester and automation; vm-operations-v1 for the explicitly requested VM-only preset; application-workloads-v1 for the recommended Application workload preset including VM, Object Storage, Observability and Secrets Manager; custom-granular for explicit permissions/automationPermissions lists; add-service-permissions ONLY when the user asks to ADD management permissions for named STACKIT services (Kubernetes/SKE = ske). This adds the official service permission set to existing granular lists and preserves the central owner and all existing permissions. Use services/permissionTarget/serviceEvidence; never copy hundreds of IDs. If several templates could be meant, ask which rather than choosing one. Mentioning a central initial owner does NOT select owner. 'granular ... kein Owner für den Besteller' is custom-granular or the requested VM preset.", - ), - services: z - .array(z.enum(permissionServices as [string, ...string[]])) - .max(60) - .default([]) - .describe( - "Official permission service prefixes explicitly requested for addition. Kubernetes cluster management is ske; do not add IAM or unrelated services. This is project IAM, not Kubernetes namespace RBAC.", - ), - permissionTarget: z - .enum(["requester", "automation", "both"]) - .default("both") - .describe( - "Who should receive the requested rights? requester = the person ordering the Landing Zone; automation = the created Application-Service-Account; both ONLY if both are intended or no recipient is mentioned. A request that someone gets rights when ordering (German: bei der Bestellung ... Rechte bekommt) explicitly means requester, NOT both. The word Application Landing Zone is not a request for automation permissions. Preserve the other recipient unchanged.", - ), - serviceEvidenceSource: z - .enum([ - "current", - ...(pendingPermissionSelection - ? [pendingPermissionSelection.source] - : []), - ]) - .default("current") - .describe( - "Use current normally. The supplied pendingPermissionSelection source may be used ONLY when the current user selects exactly that offered template, continuing the still-open original service-rights request; keep its exact services and permissionTarget. Never use arbitrary history.", - ), - serviceEvidence: z - .string() - .max(1000) - .default("") - .describe( - "Exact nonempty quote from the selected USER source requesting service permission addition. Current normally; only the supplied still-open pending selection may use its controlled history source. Never assistant statements or a catalogue.", - ), - initialOwnerEmail: z.string().max(254).default(""), - permissions: z - .array(z.string().min(1).max(256)) - .max(2000) - .default([]), - automationPermissions: z - .array(z.string().min(1).max(256)) - .max(2000) - .default([]), - }) + applicationAccess: applicationAccessIntent .nullable() .default(null) .describe( @@ -1171,7 +1252,7 @@ export class AssistantModelServing { { role: "system", content: - "Application project IAM: distinguish information questions from desired changes. An accessProfile-only change on an existing template MUST use applicationAccess, not generic templateSetup/editorInputs. Full project Owner rights explicitly requested for requester and automation select choice=owner (schemaVersion 1, mode owner), with no granular fields; do not substitute the workload default. A wish that future orderers receive Kubernetes/other service rights is an explicit template-change request, even without the words add/proposal: use topic=project-templates, mode=draft, adviceTask=other, applicationAccess.choice=add-service-permissions. Granting a future requester the ability to order/use a Kubernetes cluster means adding SKE project IAM to the existing template, NOT creating a cluster, platform Kubernetes or namespace building block. If only Public/Corporate is specified, use key=__select_by_kind__, templateKind and exact templateEvidence from the current message. The server asks which when several match. If the request concerns what the orderer receives, permissionTarget=requester; do not also change Application-service-account permissions unless requested. Preserve the existing central owner and all other settings. Actual advice questions about what roles mean use mode=advice with applicationAccess=null. Never treat a desired permission change as a request for the general access overview. If pendingPermissionSelection is supplied and the current user chooses that template, continue its original USER service request using that controlled serviceEvidenceSource and key, preserving the original services and recipient. The current selection is not a new advice request. Otherwise never reactivate old permission tasks.", + "Application project IAM: distinguish information questions from desired changes. If the CURRENT USER supplies literal arrays for both permissions and automationPermissions, choose applicationAccess custom-granular with only key, initialOwnerEmail and listOperation: the server uses those exact field-labelled lists, so do not echo them and never substitute a preset or generic templateSetup. Set listOperation=replace only for a complete desired set; choose append for Ergänze/add/append requests, preserving each recipient and central owner; choose clarify when the two recipients request different operations or the operation is unclear. Advice remains applicationAccess=null. An accessProfile-only change on an existing template MUST use applicationAccess, not generic templateSetup/editorInputs. Full project Owner rights explicitly requested for requester and automation select choice=owner (schemaVersion 1, mode owner), with no granular fields; do not substitute the workload default. A wish that future orderers receive Kubernetes/other service rights is an explicit template-change request, even without the words add/proposal: use topic=project-templates, mode=draft, adviceTask=other, applicationAccess.choice=add-service-permissions. Granting a future requester the ability to order/use a Kubernetes cluster means adding SKE project IAM to the existing template, NOT creating a cluster, platform Kubernetes or namespace building block. If only Public/Corporate is specified, use key=__select_by_kind__, templateKind and exact templateEvidence from the current message. The server asks which when several match. If the request concerns what the orderer receives, permissionTarget=requester; do not also change Application-service-account permissions unless requested. Preserve the existing central owner and all other settings. Actual advice questions about what roles mean use mode=advice with applicationAccess=null. Never treat a desired permission change as a request for the general access overview. If pendingPermissionSelection is supplied and the current user chooses that template, continue its original USER service request using that controlled serviceEvidenceSource and key, preserving the original services and recipient. The current selection is not a new advice request. Otherwise never reactivate old permission tasks.", }, { role: "system", @@ -1225,6 +1306,47 @@ export class AssistantModelServing { if (!intentResult.success) throw new AssistantInferenceError("intent_schema"); const classifiedIntent = intentResult.data; + if ( + (permissionListInput === null || + (sourcePermissionLists && + classifiedIntent.applicationAccess && + "listOperation" in classifiedIntent.applicationAccess && + classifiedIntent.applicationAccess.listOperation === "clarify")) && + classifiedIntent.mode === "draft" && + classifiedIntent.topic === "project-templates" + ) { + const dialogue = validateDialogue( + { + explanation: + classifiedIntent.applicationAccessLanguage === "en" + ? "The two permission lists or their operations are ambiguous or invalid. No changes have been proposed." + : "Die beiden Rechte-Listen sind mehrdeutig oder ungültig, oder ihre Änderungsarten sind verschieden. Es wurde keine Änderung vorgeschlagen.", + questions: [ + { + path: "projectTemplates", + text: + classifiedIntent.applicationAccessLanguage === "en" + ? "Please supply each list once and clarify whether both lists should replace the existing rights or append to them. Request different recipient operations separately." + : "Bitte gib jede Liste einmal an und kläre, ob beide Listen die bisherigen Rechte ersetzen oder ergänzen sollen. Unterschiedliche Änderungsarten bitte getrennt anfordern.", + options: [], + }, + ], + changes: [], + references: ["project-templates"], + }, + context.rules.map((rule) => rule.id), + ); + stream?.onText(dialogue.explanation); + return dialogue; + } + if ( + sourcePermissionLists && + classifiedIntent.mode === "draft" && + classifiedIntent.topic === "project-templates" && + !classifiedIntent.applicationAccess + ) + throw new AssistantInferenceError("intent_evidence"); + const policyBoundary = () => { const policyOverview = context.topics.find( (topic) => topic.id === "firewall-policy", @@ -1312,7 +1434,30 @@ export class AssistantModelServing { !context.rules.some((rule) => rule.id === "project-templates") ) throw new AssistantInferenceError("invalid_scope"); - const selected = classifiedIntent.applicationAccess; + const selected = { + templateKind: "unspecified" as "public" | "corporate" | "unspecified", + templateEvidence: "", + services: [] as string[], + permissionTarget: "both" as "requester" | "automation" | "both", + serviceEvidenceSource: "current", + serviceEvidence: "", + initialOwnerEmail: "", + ...classifiedIntent.applicationAccess, + listOperation: + "listOperation" in classifiedIntent.applicationAccess + ? classifiedIntent.applicationAccess.listOperation + : null, + permissions: + sourcePermissionLists?.permissions ?? + ("permissions" in classifiedIntent.applicationAccess + ? (classifiedIntent.applicationAccess.permissions ?? []) + : []), + automationPermissions: + sourcePermissionLists?.automationPermissions ?? + ("automationPermissions" in classifiedIntent.applicationAccess + ? (classifiedIntent.applicationAccess.automationPermissions ?? []) + : []), + }; let { key } = selected; let serviceSource = message; if (selected.serviceEvidenceSource !== "current") { @@ -1461,7 +1606,25 @@ export class AssistantModelServing { } let profile: ReturnType; try { - if (selected.choice === "add-service-permissions") { + if (sourcePermissionLists && selected.listOperation === "append") { + const previous = applicationAccessProfileSchema.parse( + template.accessProfile, + ); + if (previous.mode !== "granular") + throw new Error("literal_append_requires_granular_profile"); + profile = applicationAccessProfileSchema.parse({ + ...previous, + permissions: [ + ...new Set([...previous.permissions, ...selected.permissions]), + ], + automationPermissions: [ + ...new Set([ + ...previous.automationPermissions, + ...selected.automationPermissions, + ]), + ], + }); + } else if (selected.choice === "add-service-permissions") { const previous = applicationAccessProfileSchema.parse( template.accessProfile, ); @@ -1635,7 +1798,11 @@ export class AssistantModelServing { ? classifiedIntent.applicationAccessLanguage === "en" ? `Add catalogue permissions for ${selected.services.map((service) => (service === "ske" ? "Kubernetes (SKE)" : service)).join(", ")} to ${selected.permissionTarget}; existing permissions and central owner are preserved. ` : `Katalogrechte für ${selected.services.map((service) => (service === "ske" ? "Kubernetes (SKE)" : service)).join(", ")} ergänzen: ${selected.permissionTarget === "requester" ? "nur Besteller" : selected.permissionTarget === "automation" ? "nur Application-Service-Account" : "Besteller und Application-Service-Account"}. Bestehende Rechte und zentraler Owner bleiben erhalten. ` - : "") + + : sourcePermissionLists && selected.listOperation === "append" + ? classifiedIntent.applicationAccessLanguage === "en" + ? "Append the supplied rights to each recipient's own list; existing rights and the central owner are preserved. " + : "Die angegebenen Rechte ergänzen jeweils die eigene Empfängerliste; bestehende Rechte und zentraler Owner bleiben erhalten. " + : "") + (classifiedIntent.applicationAccessLanguage === "en" ? "Reviewable Application project IAM proposal. Nothing has been saved. Acceptance changes only the local template draft; no IDP or Configurator roles, publication or execution. Existing instances remain unchanged." : "Überprüfbarer Vorschlag für das ausgerollte Application-Projekt-IAM. Noch nichts gespeichert. Nur der lokale Template-Entwurf ändert sich nach Übernahme; keine IDP-/Configurator-Rollen, Veröffentlichung oder Ausführung. Bestehende Instanzen bleiben unverändert."), diff --git a/landing-zone-configurator/app/apps/api/src/assistant/routes.ts b/landing-zone-configurator/app/apps/api/src/assistant/routes.ts index bc2e997..f0a0d5f 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/routes.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/routes.ts @@ -115,7 +115,7 @@ export function registerAssistant( auth: AuthServices, assistant: Pick< AssistantConversations, - "get" | "send" | "accept" | "remove" + "get" | "send" | "accept" | "discard" | "remove" > & Partial< Pick< @@ -192,11 +192,20 @@ export function registerAssistant( }); } const prefix = "/api/v1/configurations/:id/assistant"; - for (const action of ["get", "send", "accept", "remove"] as const) { + for (const action of [ + "get", + "send", + "accept", + "discard", + "remove", + ] as const) { routes.route({ method: action === "get" ? "GET" : action === "remove" ? "DELETE" : "POST", - url: action === "accept" ? `${prefix}/accept` : prefix, + url: + action === "accept" || action === "discard" + ? `${prefix}/${action}` + : prefix, handler: async (request, reply) => { const session = await authenticatedSession(request, auth); if (!session) @@ -208,6 +217,10 @@ export function registerAssistant( await assistant.remove(session, id); return reply.code(204).send(); } + if (action === "discard") + return { + conversation: await assistant.discard(session, id, request.body), + }; if (action === "send") { const { revision, message, draft } = z .strictObject({ diff --git a/landing-zone-configurator/app/apps/api/src/assistant/service.ts b/landing-zone-configurator/app/apps/api/src/assistant/service.ts index eec949e..1f9b9a0 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/service.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/service.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { type AssistantMergeConflict, type AssistantProposalChange, + assistantDiscardRequestSchema, assistantProposalBaselineAvailable, assistantProposalConflicts, assistantProposalFields, @@ -62,7 +63,12 @@ export class AssistantError extends Error { } } -type Message = { id: string; role: "user" | "assistant"; content: string }; +type Message = { + id: string; + role: "user" | "assistant"; + content: string; + proposalDiscarded?: boolean; +}; type Conversation = { id: string; revision: number; @@ -660,6 +666,41 @@ export class AssistantConversations { }); } + discard(session: Session, id: string, input: unknown) { + const { conversationId, revision } = + assistantDiscardRequestSchema.parse(input); + return this.work(session, id, async (client, configuration) => { + const row = ( + await client.query( + "SELECT * FROM lzc.assistant_conversations WHERE configuration_id=$1 FOR UPDATE", + [id], + ) + ).rows[0]; + if ( + !row?.proposal || + row.id !== conversationId || + row.revision !== revision + ) + throw new AssistantError(409, "assistant_proposal_changed"); + const last = row.messages.at(-1); + if (last?.role !== "assistant") + throw new AssistantError(409, "assistant_proposal_changed"); + const messages = row.messages.map((entry, index) => + index === row.messages.length - 1 + ? { ...entry, proposalDiscarded: true } + : entry, + ); + const saved = ( + await client.query( + "UPDATE lzc.assistant_conversations SET proposal=NULL,proposal_baseline=NULL,messages=$3,revision=revision+1 WHERE id=$1 AND revision=$2 AND expires_at>now() RETURNING *", + [row.id, revision, JSON.stringify(messages)], + ) + ).rows[0]; + if (!saved) throw new AssistantError(409, "assistant_proposal_changed"); + return response(saved, configuration); + }); + } + async send( session: Session, id: string, @@ -736,7 +777,13 @@ export class AssistantConversations { groups: knowledge.groups, }, modelValues, - base.row.messages, + base.row.messages.map((entry) => ({ + role: entry.role, + content: + entry.role === "assistant" && entry.proposalDiscarded === true + ? "The user explicitly discarded the preceding proposal. This turn is closed: no proposal, selection question or setup request remains pending. Do not resume or reissue that proposal without a new explicit user request." + : entry.content, + })), message, await this.productCatalogues(session, [ base.values.region === "eu02" ? "eu02" : "eu01", diff --git a/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx b/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx index 98bb74d..44fed28 100644 --- a/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx @@ -33,7 +33,12 @@ type Conversation = { baseRevision: number; expiresAt: string; stale: boolean; - messages: { id: string; role: "user" | "assistant"; content: string }[]; + messages: { + id: string; + role: "user" | "assistant"; + content: string; + proposalDiscarded?: boolean; + }[]; proposal: { explanation: string; references: string[]; @@ -235,7 +240,7 @@ export function AssistantChat({ ); async function act( - action: "send" | "accept" | "remove", + action: "send" | "accept" | "discard" | "remove", approved?: NonNullable, outgoingMessage = message, ) { @@ -251,6 +256,7 @@ export function AssistantChat({ (!conversation || !savedDraft || selected.length === 0) ) return; + if (action === "discard" && !conversation?.proposal) return; if ( action === "remove" && !window.confirm("Gespräch und Vorschlag endgültig löschen?") @@ -283,41 +289,66 @@ export function AssistantChat({ ) throw new Error(errors.assistant_editor_changed); const response = await checked( - await fetch(action === "accept" ? `${endpoint}/accept` : endpoint, { - method: action === "remove" ? "DELETE" : "POST", - signal: signal ?? null, - headers: { - ...(action !== "remove" - ? { "Content-Type": "application/json" } - : {}), - ...(action === "send" ? { Accept: "application/x-ndjson" } : {}), - "x-lzc-csrf": session.csrfToken, - }, - ...(action === "send" - ? { - body: JSON.stringify({ - revision: binding.revision, - message: outgoingMessage, - draft, - }), - } - : action === "accept" + await fetch( + action === "accept" || action === "discard" + ? `${endpoint}/${action}` + : endpoint, + { + method: action === "remove" ? "DELETE" : "POST", + signal: signal ?? null, + headers: { + ...(action !== "remove" + ? { "Content-Type": "application/json" } + : {}), + ...(action === "send" ? { Accept: "application/x-ndjson" } : {}), + "x-lzc-csrf": session.csrfToken, + }, + ...(action === "send" ? { body: JSON.stringify({ - ...acceptance, - ...(approved - ? { confirmedRevision: approved.configurationRevision } - : {}), + revision: binding.revision, + message: outgoingMessage, + draft, }), } - : {}), - }), + : action === "discard" + ? { + body: JSON.stringify({ + conversationId: conversation?.id, + revision: conversation?.revision, + }), + } + : action === "accept" + ? { + body: JSON.stringify({ + ...acceptance, + ...(approved + ? { + confirmedRevision: approved.configurationRevision, + } + : {}), + }), + } + : {}), + }, + ), ); if (signal?.aborted) return; if (action === "remove") { setConversation(null); return; } + if (action === "discard") { + const body = await response.json(); + if (signal?.aborted) return; + setConversation(body.conversation); + setSelectedPaths(null); + setConfirmation(null); + setNotice( + "Vorschlag verworfen. Gespräch und Konfigurationsentwurf bleiben erhalten.", + ); + return; + } if (action === "send") { const body = await readAssistantResponse<{ conversation: Conversation; @@ -420,6 +451,9 @@ export function AssistantChat({
{entry.role === "user" ? "Du" : "Assistent"}

{entry.content}

+ {entry.proposalDiscarded && ( + {t("Vorschlag verworfen.")} + )}
))} {previewText !== null && ( @@ -566,8 +600,27 @@ export function AssistantChat({ : "Ausgewählte Felder übernehmen", )} + )} + {conversation?.proposal && fields.length === 0 && ( + + )}

{t( "Vorschläge werden nur in den lokalen Entwurf übernommen. Erst „Konfiguration speichern“ speichert die Änderungen. Keine Cloud-Ausführung.", diff --git a/landing-zone-configurator/app/apps/web/src/locales/en.ts b/landing-zone-configurator/app/apps/web/src/locales/en.ts index a815e11..117ae11 100644 --- a/landing-zone-configurator/app/apps/web/src/locales/en.ts +++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts @@ -2756,4 +2756,8 @@ export const englishMessages: Record = { "Your session has expired. Your draft is preserved here and can be downloaded. Sign in again to save it in the same workspace.", "Abmelden? Nicht gespeicherte Änderungen bitte zuerst speichern oder herunterladen.": "Sign out? Save or download unsaved changes first.", + "Vorschlag verwerfen": "Discard proposal", + "Vorschlag verworfen.": "Proposal discarded.", + "Vorschlag verworfen. Gespräch und Konfigurationsentwurf bleiben erhalten.": + "Proposal discarded. The conversation and configuration draft are preserved.", }; diff --git a/landing-zone-configurator/app/e2e/assistant-discard.spec.ts b/landing-zone-configurator/app/e2e/assistant-discard.spec.ts new file mode 100644 index 0000000..63f3a82 --- /dev/null +++ b/landing-zone-configurator/app/e2e/assistant-discard.spec.ts @@ -0,0 +1,266 @@ +import { createEditorConfiguration } from "@lzc/domain"; +import { expect, test } from "./fixtures"; + +for (const context of ["configuration", "templates"] as const) { + test(`discard in ${context} preserves conversation and dirty saved binding across reload and another turn`, async ({ + page, + }) => { + const user = "11111111-1111-4111-8111-111111111111"; + const tenant = "22222222-2222-4222-8222-222222222222"; + const id = "33333333-3333-4333-8333-333333333333"; + const conversationId = "55555555-5555-4555-8555-555555555555"; + const original = createEditorConfiguration( + "standalone", + "44444444-4444-4444-8444-444444444444", + ); + original.name = "Saved discard fixture"; + let stored = original; + let revision = 1; + const originalMessages = [ + { id: "question", role: "user", content: "Mein bisheriger Auftrag" }, + { id: "answer", role: "assistant", content: "Meine bisherige Antwort" }, + ]; + let conversation: { + id: string; + revision: number; + baseRevision: number; + expiresAt: string; + stale: boolean; + messages: { + id: string; + role: string; + content: string; + proposalDiscarded?: boolean; + }[]; + proposal: { + explanation: string; + questions: never[]; + changes: { path: string; before: string; value: string }[]; + references: string[]; + } | null; + } = { + id: conversationId, + revision: 2, + baseRevision: 1, + expiresAt: new Date(Date.now() + 3600000).toISOString(), + stale: false, + messages: originalMessages, + proposal: { + explanation: "Vorschlag zur Prüfung", + questions: [], + changes: [ + { path: "company_name", before: "", value: "NEVER APPLY THIS" }, + ], + references: ["identity-foundations"], + }, + }; + let discarded = 0, + sent = 0, + saves = 0; + const forbidden: string[] = []; + page.on("request", (request) => { + if ( + request.method() !== "GET" && + /\/assistant\/accept$|\/api\/v1\/(?:plans|preparations)(?:\/|$)|\/api\/v1\/applications\//.test( + new URL(request.url()).pathname, + ) + ) + forbidden.push(request.url()); + }); + await page.route("**/auth/status", (route) => + route.fulfill({ + json: { stackit: true, github: false, primary: "stackit" }, + }), + ); + await page.route("**/api/v1/session", (route) => + route.fulfill({ + json: { + user: { id: user, login: "owner@example.test" }, + tenant: { + id: tenant, + kind: "organisation", + roles: ["platform-engineer"], + }, + csrfToken: "csrf-discard", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + stackitVerified: true, + }, + }), + ); + await page.route("**/api/v1/configurations", (route) => + route.fulfill({ + json: { + configurations: [ + { + id, + name: stored.name, + revision, + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + issueCount: 0, + }, + ], + }, + }), + ); + await page.route(`**/api/v1/configurations/${id}`, (route) => { + if (route.request().method() === "PUT") { + expect(route.request().postDataJSON().revision).toBe(1); + stored = route.request().postDataJSON().draft; + revision++; + saves++; + } else expect(route.request().method()).toBe("GET"); + return route.fulfill({ + json: { configuration: { id, revision, draft: stored } }, + }); + }); + for (const [path, body] of Object.entries({ + templates: { versions: [] }, + instances: { instances: [] }, + "platform-contracts": { contracts: [] }, + })) + await page.route(`**/api/v1/applications/${path}`, (route) => + route.fulfill({ json: body }), + ); + await page.route(`**/api/v1/configurations/${id}/assistant`, (route) => { + if (route.request().method() === "POST") { + expect(route.request().headers()["x-lzc-csrf"]).toBe("csrf-discard"); + expect(route.request().postDataJSON()).toEqual({ + revision: 1, + message: "Bitte jetzt nur die Grundlagen erklären.", + draft: { ...original, name: "Unsaved preserved discard draft" }, + }); + sent++; + conversation = { + ...conversation, + revision: 4, + messages: [ + ...conversation.messages, + { + id: "new-question", + role: "user", + content: "Bitte jetzt nur die Grundlagen erklären.", + }, + { + id: "new-answer", + role: "assistant", + content: "Neue Beratung ohne früheren Vorschlag", + }, + ], + proposal: { + explanation: "Neue Beratung ohne früheren Vorschlag", + questions: [], + changes: [], + references: ["identity-foundations"], + }, + }; + } else expect(route.request().method()).toBe("GET"); + return route.fulfill({ json: { conversation } }); + }); + await page.route( + `**/api/v1/configurations/${id}/assistant/discard`, + (route) => { + expect(route.request().method()).toBe("POST"); + expect(route.request().headers()["x-lzc-csrf"]).toBe("csrf-discard"); + expect(route.request().postDataJSON()).toEqual({ + conversationId, + revision: 2, + }); + discarded++; + conversation = { + ...conversation, + revision: 3, + proposal: null, + messages: originalMessages.map((entry, index) => + index === 1 ? { ...entry, proposalDiscarded: true } : entry, + ), + }; + return route.fulfill({ json: { conversation } }); + }, + ); + await page.goto("/configurations"); + await page + .getByRole("button", { + name: "Konfiguration öffnen: Saved discard fixture", + exact: true, + }) + .click(); + await page + .getByLabel("Name der Konfiguration", { exact: true }) + .fill("Unsaved preserved discard draft"); + if (context === "templates") { + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Application Landing Zones", exact: true }) + .click(); + await page + .getByRole("tab", { name: "Template-Entwürfe", exact: true }) + .click(); + } + const chat = page.getByRole("region", { + name: "Konfigurations-Chat", + exact: true, + }); + await expect( + chat.getByText("NEVER APPLY THIS", { exact: true }), + ).toBeVisible(); + await chat + .getByRole("button", { name: "Vorschlag verwerfen", exact: true }) + .click(); + await expect( + chat.getByRole("button", { name: "Vorschlag verwerfen", exact: true }), + ).toHaveCount(0); + await expect( + chat.getByText("NEVER APPLY THIS", { exact: true }), + ).toHaveCount(0); + await expect( + chat.getByText("Mein bisheriger Auftrag", { exact: true }), + ).toBeVisible(); + await expect( + chat.getByText("Meine bisherige Antwort", { exact: true }), + ).toBeVisible(); + expect(discarded).toBe(1); + expect(sent).toBe(0); + expect(saves).toBe(0); + await expect + .poll(() => + page.evaluate( + (key) => sessionStorage.getItem(key), + `lzc-oauth-draft:${user}:${tenant}`, + ), + ) + .toContain("Unsaved preserved discard draft"); + await page.reload(); + await expect( + chat.getByText("Vorschlag verworfen.", { exact: true }), + ).toBeVisible(); + await expect( + chat.getByRole("button", { name: "Vorschlag verwerfen", exact: true }), + ).toHaveCount(0); + await chat + .getByLabel("Deine Nachricht", { exact: true }) + .fill("Bitte jetzt nur die Grundlagen erklären."); + await chat + .getByRole("button", { name: "Nachricht senden", exact: true }) + .click(); + await expect( + chat.getByText("Neue Beratung ohne früheren Vorschlag", { exact: true }), + ).toBeVisible(); + await expect(chat.locator(".assistant-proposal")).toHaveCount(0); + expect(sent).toBe(1); + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Konfigurationen", exact: true }) + .click(); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue("Unsaved preserved discard draft"); + expect(saves).toBe(0); + await page + .locator("#configuration-save") + .getByRole("button", { name: "Konfiguration speichern", exact: true }) + .click(); + await expect.poll(() => saves).toBe(1); + expect(forbidden).toEqual([]); + }); +} diff --git a/landing-zone-configurator/app/integration/identity.test.ts b/landing-zone-configurator/app/integration/identity.test.ts index 58283e3..3e74484 100644 --- a/landing-zone-configurator/app/integration/identity.test.ts +++ b/landing-zone-configurator/app/integration/identity.test.ts @@ -2116,6 +2116,157 @@ describe("real PostgreSQL session and tenant boundaries", () => { await configurations.remove(alice, stored.id, 2); }); + it("discards only the identified pending proposal without changing its configuration, and closes stale continuations", async () => { + const configurations = new Configurations(pool); + const stored = await configurations.create( + alice, + createEditorConfiguration("standalone", randomUUID()), + ); + const original = await configurations.get(alice, stored.id); + const initial: Dialogue = { + explanation: "Open proposal question that must not be resumed", + questions: [], + changes: [{ path: "company_name", value: "Never accepted" }], + references: ["configuration-contract"], + }; + const model = { converse: vi.fn(async (..._args: unknown[]) => initial) }; + const assistant = new AssistantConversations(pool, model); + const proposal = await assistant.send( + alice, + stored.id, + 1, + "Propose a company name", + ); + for (const input of [ + { conversationId: randomUUID(), revision: proposal.revision }, + { conversationId: proposal.id, revision: proposal.revision + 1 }, + ]) + await expect( + assistant.discard(alice, stored.id, input), + ).rejects.toMatchObject({ + status: 409, + code: "assistant_proposal_changed", + }); + await expect( + assistant.discard(bob, stored.id, { + conversationId: proposal.id, + revision: proposal.revision, + }), + ).rejects.toMatchObject({ status: 404 }); + expect((await assistant.get(alice, stored.id))?.proposal).not.toBeNull(); + let started!: () => void, release!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + model.converse.mockImplementationOnce(async () => { + started(); + await gate; + return initial; + }); + const inFlight = assistant.send( + alice, + stored.id, + 1, + "Prepare another draft", + ); + await ready; + const discarded = await assistant.discard(alice, stored.id, { + conversationId: proposal.id, + revision: proposal.revision, + }); + release(); + await expect(inFlight).rejects.toMatchObject({ + status: 409, + code: "assistant_conversation_changed", + }); + expect(discarded.proposal).toBeNull(); + expect(discarded.revision).toBe(proposal.revision + 1); + expect(discarded.messages).toHaveLength(proposal.messages.length); + expect( + discarded.messages.map(({ id, role, content }) => ({ + id, + role, + content, + })), + ).toEqual(proposal.messages); + expect(discarded.messages.at(-1)?.proposalDiscarded).toBe(true); + expect(model.converse).toHaveBeenCalledTimes(2); + await expect( + assistant.accept(alice, stored.id, proposal.revision, 1), + ).rejects.toMatchObject({ + status: 409, + code: "assistant_proposal_changed", + }); + await expect( + assistant.discard(alice, stored.id, { + conversationId: proposal.id, + revision: proposal.revision, + }), + ).rejects.toMatchObject({ status: 409 }); + expect(await configurations.get(alice, stored.id)).toEqual(original); + expect( + await new AssistantConversations(pool, model).get(alice, stored.id), + ).toEqual(discarded); + model.converse.mockImplementationOnce( + async (_selection, _values, history) => { + expect(history).toHaveLength(proposal.messages.length); + expect(history).toEqual([ + { + role: proposal.messages[0]?.role, + content: proposal.messages[0]?.content, + }, + expect.objectContaining({ + role: "assistant", + content: expect.stringContaining("This turn is closed"), + }), + ]); + expect(JSON.stringify(history)).not.toContain(initial.explanation); + return { + explanation: "New advice only", + questions: [], + changes: [], + references: ["configuration-contract"], + }; + }, + ); + const continued = await assistant.send( + alice, + stored.id, + 1, + "Explain the configuration instead", + ); + expect(continued.proposal?.changes).toEqual([]); + expect(continued.messages.slice(0, 2)).toEqual(discarded.messages); + expect(await configurations.get(alice, stored.id)).toEqual(original); + const fullMessages = Array.from({ length: 40 }, (_, index) => ({ + id: randomUUID(), + role: index % 2 ? "assistant" : "user", + content: `Retained message ${index}`, + })); + await migration.query( + "UPDATE lzc.assistant_conversations SET messages=$2 WHERE id=$1", + [continued.id, JSON.stringify(fullMessages)], + ); + const fullDiscarded = await assistant.discard(alice, stored.id, { + conversationId: continued.id, + revision: continued.revision, + }); + expect(fullDiscarded.messages).toHaveLength(40); + expect(fullDiscarded.messages.slice(0, 39)).toEqual( + fullMessages.slice(0, 39), + ); + expect(fullDiscarded.messages[39]).toEqual({ + ...fullMessages[39], + proposalDiscarded: true, + }); + expect(model.converse).toHaveBeenCalledTimes(3); + expect(await configurations.get(alice, stored.id)).toEqual(original); + await configurations.remove(alice, stored.id, 1); + }); + it("persists assistant conversations, accepts only current proposals and cascades parent deletion", async () => { const configurations = new Configurations(pool); const stored = await configurations.create( diff --git a/landing-zone-configurator/app/packages/contracts/src/assistant.ts b/landing-zone-configurator/app/packages/contracts/src/assistant.ts index 33a4a1a..cd5b501 100644 --- a/landing-zone-configurator/app/packages/contracts/src/assistant.ts +++ b/landing-zone-configurator/app/packages/contracts/src/assistant.ts @@ -20,6 +20,11 @@ export const assistantInferenceFailureSchema = z.object({ .regex(/^[a-zA-Z0-9_-]+$/), }); +export const assistantDiscardRequestSchema = z.strictObject({ + conversationId: z.uuid(), + revision: z.number().int().positive(), +}); + export type AssistantFailureReason = z.infer< typeof assistantInferenceFailureSchema >["reason"]; diff --git a/landing-zone-configurator/app/scripts/assistant-scenarios.ts b/landing-zone-configurator/app/scripts/assistant-scenarios.ts index 160edcb..5347eb2 100644 --- a/landing-zone-configurator/app/scripts/assistant-scenarios.ts +++ b/landing-zone-configurator/app/scripts/assistant-scenarios.ts @@ -65,6 +65,22 @@ const questions: Record = { }; const overviewFields: Record = { + "platform-kubernetes": [ + "platform_kubernetes", + "region", + "cluster", + "kubernetes_version_min", + "node_pools", + "minimum", + "maximum", + "availability_zones", + "maintenance", + "network", + "sna_enabled", + "network_area_key", + "sna_network_prefix_length", + "platform_kubernetes_kube_config_override", + ], "devops-git": ["devops", "git_flavor", "allowed_network_ranges"], vpn: [ "connectivity.vpn", @@ -136,6 +152,7 @@ export type AssistantScenario = { | "advice" | "owner" | "granular" + | "literal-append" | "vm-default" | "workload-default" | "kubernetes-add" @@ -264,6 +281,14 @@ export const assistantScenarios: readonly AssistantScenario[] = [ message: "Ändere ausschließlich das accessProfile der bestehenden Corporate-Vorlage central auf granular, schemaVersion 1. Zentraler initialOwnerEmail ist platform-owner@example.test. Für das Team exakt permissions [iaas.server.get, resource-manager.project.get], für Automation exakt automationPermissions [iaas.server.get]. Kein Owner für den Besteller. Keine weiteren Änderungen, keine Veröffentlichung/Ausführung. Vollständiger atomarer Editor-Vorschlag.", }, + { + id: "application-access-literal-permissions-append", + topic: "project-templates", + fixture: "corporate-ipv4", + applicationAccess: "literal-append", + message: + "Ergänze ausschließlich das accessProfile der bestehenden Corporate-Vorlage central: permissions [iaas.server.delete] und automationPermissions [resource-manager.project.get]. Die bisherigen unterschiedlichen Rechte jeder Empfängerliste und den zentralen initialen Owner unverändert erhalten, nichts ersetzen oder entfernen. Nur überprüfbarer Vorschlag, nichts speichern, veröffentlichen oder ausführen.", + }, { id: "application-access-kubernetes-add", topic: "project-templates", @@ -909,6 +934,17 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { }, ]; } + if (scenario.applicationAccess === "literal-append") { + const appendTemplate = draft.projectTemplates?.[0]; + if (!appendTemplate) throw new Error("missing literal append template"); + appendTemplate.accessProfile = { + schemaVersion: 1, + mode: "granular", + initialOwnerEmail: "platform-owner@example.test", + permissions: ["iaas.server.get", "secrets-manager.secret.get"], + automationPermissions: ["iaas.server.get"], + }; + } if (scenario.applicationAccess?.startsWith("public-kubernetes-")) { const publicTemplate = draft.projectTemplates![0]!; publicTemplate.kind = "public"; @@ -1055,6 +1091,21 @@ export function validateAssistantScenario( permissions: ["iaas.server.get", "resource-manager.project.get"], automationPermissions: ["iaas.server.get"], }; + if (scenario.applicationAccess === "literal-append") { + const previous = fixture.draft.projectTemplates?.find( + (t) => t.key === "central", + )?.accessProfile; + if (!previous || previous.mode !== "granular") + throw new Error("expected granular append fixture"); + central.accessProfile = { + ...previous, + permissions: [...previous.permissions, "iaas.server.delete"], + automationPermissions: [ + ...previous.automationPermissions, + "resource-manager.project.get", + ], + }; + } if ( scenario.applicationAccess === "kubernetes-add" || scenario.applicationAccess === "kubernetes-requester" || @@ -1477,6 +1528,19 @@ export function validateAssistantScenario( ) ) failures.push("incomplete_native_field_overview"); + if ( + scenario.id === "platform-kubernetes-advice" && + (!/kubeconfig|kube_config/i.test(dialogue.explanation) || + !/separat|geschützt|protected|secure binding/i.test( + dialogue.explanation, + ) || + !/(?:niemals|nicht|keine?|never|do not)[^.!?\n]*chat/i.test( + dialogue.explanation, + ) || + !/erreichbarkeit|reachability/i.test(dialogue.explanation) || + !/prüf|pruef|verif|check/i.test(dialogue.explanation)) + ) + failures.push("incomplete_platform_kubernetes_boundaries"); if ( scenario.id === "governance-folders-advice" && (!/\b40\b/.test(dialogue.explanation) || @@ -1883,7 +1947,13 @@ export function assistantCorporateIpv4Failures(explanation: string): string[] { !/explizit|explicit|numerisch|numeric/.test(text) ) failures.push("corporate_ipv4_provider_dependency_missing"); + // German separable "beibehalten" also appears as "behält DNS ... bei". + // Require the DNS object and particle in the same sentence, without negation. + const keepsDns = [ + ...text.matchAll(/\b(?:behält|behaelt)\b[^.!?]*\bdns\b[^.!?]*\bbei\b/g), + ].some((match) => !/\b(?:nicht|kein\w*)\b/.test(match[0])); if ( + !keepsDns && !/dns[^.!?]*(?:erhalt|unverändert|unveraendert|beibeh|beibe|retain|preserv|bleibt aktiv)|(?:erhalt|beibeh|beibe|unverändert|unveraendert|retain|preserv)[^.!?]*dns/.test( text, ) diff --git a/landing-zone-configurator/app/tests/assistant-discard.test.ts b/landing-zone-configurator/app/tests/assistant-discard.test.ts new file mode 100644 index 0000000..9f6eab2 --- /dev/null +++ b/landing-zone-configurator/app/tests/assistant-discard.test.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { assistantDiscardRequestSchema } from "@lzc/contracts"; +import { expect, it, vi } from "vitest"; +import { buildApp } from "../apps/api/src/app.js"; +import type { AuthServices } from "../apps/api/src/auth/routes.js"; +import type { Session } from "../apps/api/src/auth/store.js"; + +it("authorizes proposal discard through the existing session, origin, CSRF and strict identity contract", async () => { + const session: Session = { + id: randomUUID(), + userId: randomUUID(), + tenantId: randomUUID(), + githubId: "", + login: "alice", + csrfToken: "a".repeat(43), + expiresAt: new Date(Date.now() + 3600000), + }; + const auth = { + origin: "https://configurator.example", + store: { resolveSession: vi.fn(async () => session) }, + } as unknown as AuthServices; + const conversation = { + id: randomUUID(), + revision: 3, + baseRevision: 1, + expiresAt: new Date().toISOString(), + messages: [], + proposal: null, + stale: false, + authorizesExecution: false, + }; + const assistant = { + get: vi.fn(async () => conversation), + send: vi.fn(), + accept: vi.fn(), + remove: vi.fn(), + discard: vi.fn(async (_session: Session, _id: string, input: unknown) => { + assistantDiscardRequestSchema.parse(input); + return conversation; + }), + }; + const app = buildApp({ auth, assistant }); + const id = randomUUID(); + const url = `/api/v1/configurations/${id}/assistant/discard`; + const headers = { + cookie: `__Host-lzc-session=${"b".repeat(43)}`, + origin: auth.origin, + "x-lzc-csrf": session.csrfToken, + }; + const payload = { conversationId: conversation.id, revision: 2 }; + try { + expect( + (await app.inject({ method: "POST", url, payload })).statusCode, + ).toBe(401); + expect( + ( + await app.inject({ + method: "POST", + url, + payload, + headers: { ...headers, origin: "https://other.example" }, + }) + ).statusCode, + ).toBe(403); + expect( + ( + await app.inject({ + method: "POST", + url, + payload, + headers: { ...headers, "x-lzc-csrf": "wrong" }, + }) + ).statusCode, + ).toBe(403); + expect(assistant.discard).not.toHaveBeenCalled(); + expect( + ( + await app.inject({ + method: "POST", + url, + headers, + payload: { ...payload, tenantId: randomUUID() }, + }) + ).statusCode, + ).toBe(400); + assistant.discard.mockClear(); + const reply = await app.inject({ method: "POST", url, headers, payload }); + expect(reply.statusCode).toBe(200); + expect(reply.json()).toEqual({ conversation }); + expect(assistant.discard).toHaveBeenCalledExactlyOnceWith( + session, + id, + payload, + ); + expect(assistant.send).not.toHaveBeenCalled(); + expect(assistant.accept).not.toHaveBeenCalled(); + expect(assistant.remove).not.toHaveBeenCalled(); + } finally { + await app.close(); + } +}); diff --git a/landing-zone-configurator/app/tests/assistant-editor.test.ts b/landing-zone-configurator/app/tests/assistant-editor.test.ts index 9f7094e..2e113cc 100644 --- a/landing-zone-configurator/app/tests/assistant-editor.test.ts +++ b/landing-zone-configurator/app/tests/assistant-editor.test.ts @@ -1750,9 +1750,13 @@ it.each(["owner", "granular"] as const)( applicationAccess: { key: template.key, choice: mode === "owner" ? "owner" : "custom-granular", - initialOwnerEmail: "central@example.test", - permissions: ["iaas.server.get"], - automationPermissions: ["iaas.server.get"], + ...(mode === "owner" + ? {} + : { + initialOwnerEmail: "central@example.test", + permissions: ["iaas.server.get"], + automationPermissions: ["iaas.server.get"], + }), }, }, null, @@ -1885,8 +1889,6 @@ it.each(["vm-operations-v1", "application-workloads-v1", "missing-owner"])( choice === "application-workloads-v1" ? choice : "vm-operations-v1", initialOwnerEmail: choice === "missing-owner" ? "" : "central@example.test", - permissions: [], - automationPermissions: [], }, }, null, @@ -2447,3 +2449,498 @@ it.each([ expect(original).toEqual(before); }, ); + +it.each([ + "owner", + "vm-operations-v1", + "application-workloads-v1", + "add-service-permissions", +])( + "rejects expanded permission lists in the compact %s intent arm", + async (choice) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "corporate", + )!; + template.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + const profile = defaultApplicationAccessProfile("central@example.test"); + if (profile.mode !== "granular") throw new Error("fixture"); + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice, + permissions: profile.permissions, + ...(choice === "owner" + ? {} + : choice === "add-service-permissions" + ? { services: ["ske"], serviceEvidence: "Kubernetes" } + : { initialOwnerEmail: "central@example.test" }), + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + `Für Corporate ${template.key} Kubernetes-Rechte vorschlagen.`, + ), + ).rejects.toMatchObject({ reason: "intent_schema" }); + }, +); + +it("limits permission-list generation to the explicit custom IAM serving arm", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "corporate", + )!; + let contract: unknown; + await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "application-workloads-v1", + initialOwnerEmail: "central@example.test", + }, + }, + null, + (request) => { + contract = request.response_format.json_schema.schema; + }, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Workload-Standard mit central@example.test als initialem Owner vorschlagen.", + ); + const access = ( + contract as { properties: { applicationAccess: Record } } + ).properties.applicationAccess; + expect(JSON.stringify(access)).not.toContain('"oneOf"'); + const arms: Record[] = []; + function visit(value: unknown) { + if (!value || typeof value !== "object") return; + const obj = value as Record; + const properties = obj.properties as Record | undefined; + if (properties?.choice) arms.push(obj); + if (Array.isArray(obj.anyOf)) obj.anyOf.forEach(visit); + } + visit(access); + expect(arms).toHaveLength(4); + for (const arm of arms) { + const properties = arm.properties as Record; + const choice = properties.choice as { const?: string; enum?: string[] }; + expect(arm.additionalProperties).toBe(false); + expect(Object.hasOwn(properties, "permissions")).toBe( + choice.const === "custom-granular", + ); + expect(Object.hasOwn(properties, "automationPermissions")).toBe( + choice.const === "custom-granular", + ); + } +}); + +it.each([ + "permissions [iaas.server.get, resource-manager.project.get], automationPermissions [iaas.server.get]", + '"permissions": ["iaas.server.get", "resource-manager.project.get"], "automationPermissions": ["iaas.server.get"]', + "`permissions` = ['iaas.server.get', 'resource-manager.project.get'], `automationPermissions`: ['iaas.server.get']", +])( + "takes exact recipient lists from the current literal source: %s", + async (lists) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "corporate", + )!; + template.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + const before = structuredClone(original); + let contract: unknown; + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "custom-granular", + listOperation: "replace", + initialOwnerEmail: "central@example.test", + }, + }, + null, + (request) => { + contract = request.response_format.json_schema.schema; + }, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + `Setze für ${template.key} ein individuelles Profil mit central@example.test als initialem Owner: ${lists}. Exakt diese Listen.`, + ); + expect(original).toEqual(before); + const expected = structuredClone(original); + expected.projectTemplates!.find( + (t) => t.id === template.id, + )!.accessProfile = { + schemaVersion: 1, + mode: "granular", + initialOwnerEmail: "central@example.test", + permissions: ["iaas.server.get", "resource-manager.project.get"], + automationPermissions: ["iaas.server.get"], + }; + expect(applyAssistantChanges(original, result.changes)).toEqual(expected); + const access = (contract as { properties: { applicationAccess: unknown } }) + .properties.applicationAccess; + expect(JSON.stringify(access)).not.toContain('"automationPermissions"'); + expect(JSON.stringify(access)).not.toContain('"permissions"'); + }, +); + +it.each(["preset", "extra-saved-right", "recipient-swap"])( + "rejects model-owned %s changes to explicit source-owned recipient lists", + async (failure) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "corporate", + )!; + template.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + const wrong = + failure === "preset" + ? { choice: "application-workloads-v1" } + : { + choice: "custom-granular", + listOperation: "replace", + permissions: + failure === "recipient-swap" + ? ["iaas.server.get"] + : [ + "iaas.server.get", + "resource-manager.project.get", + "iaas.server.delete", + ], + automationPermissions: [ + "iaas.server.get", + "resource-manager.project.get", + ], + }; + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + initialOwnerEmail: "central@example.test", + ...wrong, + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Setze permissions [iaas.server.get, resource-manager.project.get] und automationPermissions [iaas.server.get], initialOwnerEmail central@example.test.", + ), + ).rejects.toMatchObject({ reason: "intent_schema" }); + }, +); + +it.each([ + "permissions [iaas.server.get], permissions [iaas.server.delete], automationPermissions [iaas.server.get]", + "permissions [iaas.server.get], automationPermissions [not a permission]", + "permissions [iaas.server.get], automationPermissions [iaas.server.get", +])( + "asks rather than substituting a preset for invalid literal lists: %s", + async (lists) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "corporate", + )!; + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "application-workloads-v1", + initialOwnerEmail: "central@example.test", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + `Setze ${lists}.`, + ); + expect(result.changes).toEqual([]); + expect(result.questions).toHaveLength(1); + expect(result.explanation).toContain("mehrdeutig oder ungültig"); + }, +); + +it("does not turn literal permission-list advice into a proposal", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const result = await collectionModel( + { + topic: "project-templates", + mode: "advice", + adviceTask: "application-access", + requirements: [], + applicationAccess: null, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Was bedeuten permissions [iaas.server.get] und automationPermissions [iaas.server.get]? Nur erklären, nichts ändern.", + ); + expect(result.changes).toEqual([]); +}); + +it("never expands an explicit empty list into standard permissions", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "corporate", + )!; + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "custom-granular", + listOperation: "replace", + initialOwnerEmail: "central@example.test", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Setze permissions [] und automationPermissions [iaas.server.get], initialOwnerEmail central@example.test.", + ), + ).rejects.toBeInstanceOf(AssistantInferenceError); +}); + +it("appends source-owned literal permissions independently without removing prior rights or changing the central owner", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates?.find( + (t) => t.kind === "corporate", + ); + if (!template) throw new Error("missing corporate fixture"); + template.accessProfile = { + schemaVersion: 1, + mode: "granular", + initialOwnerEmail: "retained-owner@example.test", + permissions: ["iaas.server.get", "secrets-manager.secret.get"], + automationPermissions: ["resource-manager.project.get"], + }; + const before = structuredClone(original); + let contract: unknown; + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "custom-granular", + listOperation: "append", + initialOwnerEmail: "", + }, + }, + null, + (request) => { + contract = request.response_format.json_schema.schema; + }, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Ergänze permissions [iaas.server.delete, iaas.server.get] und automationPermissions [iaas.server.get]. Bestehende Rechte und zentralen Owner erhalten.", + ); + expect(original).toEqual(before); + const expected = structuredClone(original); + expected.projectTemplates!.find((t) => t.id === template.id)!.accessProfile = + { + ...template.accessProfile, + permissions: [ + "iaas.server.get", + "secrets-manager.secret.get", + "iaas.server.delete", + ], + automationPermissions: [ + "resource-manager.project.get", + "iaas.server.get", + ], + }; + expect(applyAssistantChanges(original, result.changes)).toEqual(expected); + expect(result.explanation).toContain( + "bestehende Rechte und zentraler Owner bleiben erhalten", + ); + const access = ( + contract as { + properties: { + applicationAccess: { + anyOf: { required?: string[]; properties?: { choice?: unknown } }[]; + }; + }; + } + ).properties.applicationAccess; + const custom = access.anyOf.find((arm) => arm.properties?.choice); + expect(custom?.required).toContain("listOperation"); +}); + +it("never downgrades full Owner rights into a granular profile when literal additions are requested", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates?.find( + (t) => t.kind === "corporate", + ); + if (!template) throw new Error("missing corporate fixture"); + template.accessProfile = { schemaVersion: 1, mode: "owner" }; + const before = structuredClone(original); + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "custom-granular", + listOperation: "append", + initialOwnerEmail: "invented@example.test", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Ergänze permissions [iaas.server.get] und automationPermissions [resource-manager.project.get].", + ), + ).rejects.toBeInstanceOf(AssistantInferenceError); + expect(original).toEqual(before); +}); + +it("asks without changes when literal recipients need different list operations", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates?.find( + (t) => t.kind === "corporate", + ); + if (!template) throw new Error("missing corporate fixture"); + template.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + const before = structuredClone(original); + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "custom-granular", + listOperation: "clarify", + initialOwnerEmail: "central@example.test", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Ergänze permissions [iaas.server.get], aber ersetze automationPermissions [resource-manager.project.get].", + ); + expect(result.changes).toEqual([]); + expect(result.questions).toHaveLength(1); + expect(result.questions[0]?.text).toContain( + "Unterschiedliche Änderungsarten", + ); + expect(original).toEqual(before); +}); + +it("rejects a missing literal-list operation instead of defaulting an addition to destructive replacement", async () => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates?.find( + (t) => t.kind === "corporate", + ); + if (!template) throw new Error("missing corporate fixture"); + template.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + const before = structuredClone(original); + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: template.key, + choice: "custom-granular", + initialOwnerEmail: "central@example.test", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Ergänze permissions [iaas.server.delete] und automationPermissions [resource-manager.project.get]. Alle bisherigen Rechte erhalten.", + ), + ).rejects.toMatchObject({ reason: "intent_schema" }); + expect(original).toEqual(before); +}); diff --git a/landing-zone-configurator/app/tests/assistant-scenarios.test.ts b/landing-zone-configurator/app/tests/assistant-scenarios.test.ts index 5e25905..2d25652 100644 --- a/landing-zone-configurator/app/tests/assistant-scenarios.test.ts +++ b/landing-zone-configurator/app/tests/assistant-scenarios.test.ts @@ -272,9 +272,9 @@ it.each(assistantScenarios)( ? "Granulare VM-Rechte empfohlen. IDP bestätigt Identität; Projekt-IAM ist separat. Zentraler initialer Owner und Workload-Automation sind getrennt. Voller Owner bleibt Kundenwahl; geerbte Rechte sind additiv." : scenario.corporateIpv4 === "advice" ? "Automatisch übernimmt das Standardpräfix /28 der SNA. Der Provider benötigt bei gesetztem DNS ein explizites Präfix; das native Modul liest es im Plan aus der SNA und übergibt es explizit. DNS bleibt erhalten. Freie Kapazität ist nicht garantiert." - : scenario.topic === "vpn" + : ["vpn", "platform-kubernetes"].includes(scenario.topic) ? buildAssistantKnowledge().topics.find( - (topic) => topic.id === "vpn", + (topic) => topic.id === scenario.topic, )?.fieldOverview : `Nur Beratung, keine Speicherung oder Ausfuehrung. ${scenario.requiredOverviewFields?.join(", ") ?? ""}. Namen 1-40 Zeichen; Beschreibungen nicht unterstützt und abgelehnt. pass, block, reject; explizite Synchronisierung. ${scenario.dnsRole ? dnsRoleExplanation : ""} git_flavor=null erstellt keine Git-Instanz; git-10 ist kein automatisch aktivierter Standard.`, questions: [], @@ -416,6 +416,7 @@ it.each([ "sandbox-projects-advice", "vpn-advice", "devops-git-advice", + "platform-kubernetes-advice", ])( "rejects a correct reference but empty or misleading native field overview for %s", (id) => { @@ -708,6 +709,35 @@ it("requires factual Automatic/DNS advice and rejects fixed-size or DNS-removal ).toContain("corporate_ipv4_invented_fixed_size_or_dns_removal"); }); +it.each(["behält", "behaelt"])( + "recognizes inflected DNS preservation (%s ... bei) without accepting negation or another object's preservation", + (verb) => { + const facts = + "Automatisch liest das Corporate-Modul den SNA-Standard /28 und übergibt dem Provider den numerischen Präfix. Freie Kapazität ist nicht garantiert."; + // Exact sentence from the failed live receipt; its factual claims were correct. + const originalSentence = `Mit 'Automatisch' und SNA-Default /28 erhält der Provider korrekt prefix: 28 und ${verb} die DNS-Server bei.`; + const correct = `${facts} ${originalSentence}`; + expect(assistantCorporateIpv4Failures(correct)).toEqual([]); + for (const claim of [ + `${verb} die DNS-Server nicht bei`, + `${verb} keine DNS-Server bei`, + `${verb} die VM-Konfiguration bei; DNS wird deaktiviert`, + `${verb} die VM-Konfiguration bei. DNS ist konfiguriert`, + ]) + expect( + assistantCorporateIpv4Failures(`${facts} Der Provider ${claim}.`), + ).toContain("corporate_ipv4_dns_preservation_missing"); + expect( + assistantCorporateIpv4Failures( + `${correct} DNS muss abgeschaltet werden.`, + ), + ).toContain("corporate_ipv4_invented_fixed_size_or_dns_removal"); + expect( + assistantCorporateIpv4Failures(`${correct} Du musst /24 setzen.`), + ).toContain("corporate_ipv4_invented_fixed_size_or_dns_removal"); + }, +); + it("accepts numeric-prefix wording and DNS with an IP while rejecting inverted cause/reader claims", () => { const correct = "Automatisch liest das Corporate-Modul den SNA-Standard /28 und übergibt einen numerischen Präfix an den Provider. Der DNS-Eintrag (hier: 1.1.1.1) bleibt unverändert erhalten. Freie Kapazität ist nicht garantiert."; @@ -798,3 +828,40 @@ it.each(["automatic", "explicit"] as const)( ).toContain("corporate_ipv4_proposal_incorrect_or_unrelated_change"); }, ); + +it("requires Kubernetes network and protected-access substance instead of repetitive meta-advice", () => { + const scenario = assistantScenarios.find( + (entry) => entry.id === "platform-kubernetes-advice", + ); + const explanation = buildAssistantKnowledge().topics.find( + (topic) => topic.id === "platform-kubernetes", + )?.fieldOverview; + if (!scenario || !explanation) throw new Error("missing_kubernetes_overview"); + const input = { + explanation, + questions: [], + changes: [], + references: ["platform-kubernetes"], + }; + expect(validateAssistantScenario(scenario, input).failures).toEqual([]); + const repeated = + "Keine Erklärung von nicht unterstützten Feldern oder Formen. Keine Erwähnung von Firewall oder VPN. ".repeat( + 15, + ); + expect( + validateAssistantScenario(scenario, { ...input, explanation: repeated }) + .failures, + ).toContain("incomplete_native_field_overview"); + expect( + validateAssistantScenario(scenario, { + ...input, + explanation: `${scenario.requiredOverviewFields?.join(", ")}. ${repeated}`, + }).failures, + ).toContain("incomplete_platform_kubernetes_boundaries"); + expect( + validateAssistantScenario(scenario, { + ...input, + explanation: `${scenario.requiredOverviewFields?.join(", ")}. Bitte Kubeconfig-Inhalte im Chat eintragen.`, + }).failures, + ).toContain("incomplete_platform_kubernetes_boundaries"); +}); diff --git a/landing-zone-configurator/app/tests/assistant.test.ts b/landing-zone-configurator/app/tests/assistant.test.ts index 95072b1..c4b3538 100644 --- a/landing-zone-configurator/app/tests/assistant.test.ts +++ b/landing-zone-configurator/app/tests/assistant.test.ts @@ -1948,6 +1948,7 @@ it("requires login, current origin and CSRF for chat and rejects injected tenant }, ), accept: vi.fn(), + discard: vi.fn(), preview: vi.fn(), remove: vi.fn(), getTemplateAdvice: vi.fn(async () => null), @@ -2566,6 +2567,7 @@ it.each([ "vpn", "devops-git", "dns-zones", + "platform-kubernetes", ])( "answers %s field overviews from reviewed native metadata, not incomplete model prose", async (topic) => { @@ -2624,6 +2626,19 @@ it.each([ ]) expect(result.explanation).toContain(field); } + if (topic === "platform-kubernetes") { + for (const field of [ + "node_pools", + "minimum", + "maximum", + "sna_network_prefix_length", + "platform_kubernetes_kube_config_override", + ]) + expect(result.explanation).toContain(field); + expect(result.explanation).toContain("separater geschützter Eingang"); + expect(result.explanation).toContain("niemals Kubeconfig-Inhalte"); + expect(result.explanation).toContain("Erreichbarkeit"); + } expect(result.explanation).not.toContain("Untrusted private value"); expect(result.explanation).not.toContain("private@example.test"); expect(result.questions).toEqual([]); diff --git a/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.de.md b/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.de.md index eab2a33..0bfa1f3 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.de.md +++ b/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.de.md @@ -19,17 +19,19 @@ Dies sind getrennte Entscheidungen, keine impliziten Teile einer SNA-Anfrage. ## Accelerator-Vertrag -Natives `platform_kubernetes` ist eine Map mit Schluesseln. -Eintraege definieren Region, Cluster (`name/version/node_pools/maintenance`), -`network`, `dns`, `observability`, `encrypted_volumes`, `debug_bastion` und -`role_assignments`. -Netzwerkeinstellungen umfassen `sna_enabled`, `network_area_key` und `prefix`; -Root loest native Area-Referenzen oder eine explizit gebundene externe Area auf. -Regionale Modulaufrufe waehlen eu01/eu02. -KMS-gestuetzte Storage-Class-Konfiguration unterscheidet sich von der -Aktivierung von Verschluesselung fuer alle Volumes. -Debug-Bastion ist ein getrenntes VM-/SSH-Zugangsfeature. -Geschuetztes `platform_kubernetes_kube_config_override` ist keine normale +`platform_kubernetes` ist eine Map mit stabilen Schluesseln. +Jeder Eintrag enthaelt `region` und `cluster` mit `name`, +`kubernetes_version_min`, `node_pools` und `maintenance`. +Pools bestimmen `name`, `machine_type`, `minimum`/`maximum`, +`availability_zones` und optionale Systemkomponenten-, Volume-, OS- und Label-Werte. +`network` enthaelt `sna_enabled`, `network_area_key`, `sna_network_area_id`, +`firewall_next_hop_ip` und `sna_network_prefix_length`; Root loest native +Area-Schluessel oder eine explizit gebundene externe Area fuer eu01/eu02 auf. +`dns`, `observability`, `encrypted_volumes`, `debug_bastion` und +`role_assignments` sind getrennte Optionen. +KMS-gestuetzte Storage-Classes verschluesseln nicht automatisch alle Volumes. +Debug-Bastion ist ein getrenntes VM-/SSH-Feature. +Geschuetztes `platform_kubernetes_kube_config_override` ist keine oeffentliche Chat-Konfiguration. @@ -61,8 +63,8 @@ aktivieren, nur um SNA zu testen. Synthetische Deaktivierung: `platform_kubernetes = {}`. Ein aktivierter Eintrag benoetigt bestaetigte Cluster-/Node-Pool-Einstellungen; -ein /24-Netzpraefix oder Schluessel-Default reicht nicht fuer Zusagen -ueber 100 Workloads, Cluster-Quoten oder Isolation je Namespace. +ein /24-Netzpraefix oder Schluessel-Default garantiert weder freie Adressen, +Workload-Kapazitaet, Cluster-Quoten noch Isolation je Namespace. diff --git a/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.md b/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.md index 463c380..472d593 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.md +++ b/landing-zone-configurator/docs/assistant-knowledge/platform-kubernetes.md @@ -2,20 +2,22 @@ ## Purpose -Create shared STACKIT Kubernetes infrastructure with deliberate networking, -node pools and supported optional DNS, monitoring, encrypted storage and debug -access. These are separate choices, not implicit parts of an SNA request. +Configure shared STACKIT Kubernetes with deliberate networking and node pools. +DNS, monitoring, encrypted storage and debug access are separate choices, not +implicit parts of an SNA request. ## Accelerator Contract -Native platform_kubernetes is a keyed map. Entries define region, cluster -(name/version/node_pools/maintenance), network, dns, observability, -encrypted_volumes, debug_bastion and role_assignments. Network settings include -sna_enabled, network_area_key and prefix; root resolves native area references -or an explicitly bound external area. Regional module calls select eu01/eu02. -KMS-backed storage-class configuration differs from enabling all-volume -encryption. Debug bastion is a separate VM/SSH access feature. Protected -platform_kubernetes_kube_config_override is not normal chat configuration. +platform_kubernetes is a keyed map. Each entry has region and cluster with +name, kubernetes_version_min, node_pools and maintenance. Pools define name, +machine_type, minimum/maximum, availability_zones and optional system-component, +volume, OS and label settings. network uses sna_enabled, network_area_key, +sna_network_area_id, firewall_next_hop_ip and sna_network_prefix_length; root +resolves native area keys or an explicitly bound external area for eu01/eu02. +dns, observability, encrypted_volumes, debug_bastion and role_assignments are +separate options. KMS-backed storage classes do not encrypt every volume. +Debug bastion is a separate VM/SSH feature. Protected +platform_kubernetes_kube_config_override is not public chat configuration. ## Dependencies @@ -26,33 +28,32 @@ path. Public mode is not private reachability by assumption. ## Dialogue -Ask about workload scale, private/public connectivity, node-pool/system workload -roles, availability, maintenance, storage encryption and operational access. +Ask about scale, connectivity, system/workload pools, availability, maintenance, +encryption and operational access. Use live versions/machines/zones/volumes, not static default SKUs. Never create a bastion, expose SSH to all networks or enable demos merely to "test SNA". ## Examples -Synthetic opt-out: platform_kubernetes = {}. An enabled entry needs confirmed -cluster/node-pool settings; a /24 network prefix or key default is not enough -to promise 100 workloads, cluster quotas or per-namespace isolation. +Synthetic opt-out: platform_kubernetes = {}. Enabled clusters need confirmed +cluster/pool settings; a /24 or key default guarantees neither free addresses, +workload capacity, quotas nor namespace isolation. ## Limits -Native Editor proposals support reviewed public cluster/KMS/bastion settings, -not credentials or deployment. Validate region, -prefix bounds, node min/max, system pools and area references. Template defaults -may contain region-specific zones. In-cluster connectivity, provider access, -KMS use and node health need separate real execution qualification. +The Editor supports reviewed public cluster/KMS/bastion drafts, not credentials +or deployment. Validate region, prefixes, pool minimum/maximum, system pools +and area references. Defaults may contain regional zones. Connectivity, provider +access, KMS and node health need separate execution qualification. ## Credentials -Provider/KMS rights, Kubernetes access and bastion SSH are distinct secure -bindings. Never request kubeconfig contents, private SSH keys or KMS secret -material; externally supplied overrides must work with the native contract. +Provider/KMS rights, Kubernetes access and bastion SSH need distinct secure +bindings. Never request kubeconfig, private SSH keys or KMS secrets; external +overrides must match the native contract. ## Sources -Root calls, cluster/bastion variables, providers and shared validation define -the reviewed scope. Re-review DNS/region/provider and encryption changes before -generation; Markdown does not certify private-cluster execution. \ No newline at end of file +Root calls, cluster/bastion variables, providers and validation define this +scope. Review DNS, region, provider and encryption changes before generation. +Markdown does not certify private-cluster execution. \ No newline at end of file diff --git a/landing-zone-configurator/docs/assistant-test-catalogue.json b/landing-zone-configurator/docs/assistant-test-catalogue.json index 7ad821a..2727fb9 100644 --- a/landing-zone-configurator/docs/assistant-test-catalogue.json +++ b/landing-zone-configurator/docs/assistant-test-catalogue.json @@ -2,12 +2,12 @@ "schemaVersion": 1, "status": "field-inventory-and-smoke-corpus-not-ai-qualification", "engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2", - "knowledgeSha256": "1014410747d59386c02dfc89e37b2e0b2d2655c4d1ac42a8468f658972286dde", + "knowledgeSha256": "97dbf413ea6025c7ad3a6b14f8c6e6742ecb536d236fc775293c00b3071fe137", "sourceDigests": { - "scripts/assistant-scenarios.ts": "71ea9733d4e93b976d16ffd75902e0ecb9472880e24d352a4a291c8165b433d2", + "scripts/assistant-scenarios.ts": "ce04f4319f089ae9269e2482aaa8bfd66bcf2a1a4617d504146edb757bd7266d", "scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d" }, - "suiteSha256": "407f960fab9817a793f5c282d181aeb6a749215baf3ced2778cb1dadef42de10", + "suiteSha256": "a42cbfa041f8382c8bb4a4b25bae09f6d90269960194cdd97ac7edd69be82c6d", "topics": [ { "id": "credential-foundations", @@ -5004,9 +5004,26 @@ "topic": "platform-kubernetes", "message": "Welche Einstellungen bietet platform_kubernetes fuer SKE, Node-Pools und Netzanbindung? Wie wird Kubeconfig separat behandelt? Nur beraten.", "fixture": "baseline", + "requiredOverviewFields": [ + "platform_kubernetes", + "region", + "cluster", + "kubernetes_version_min", + "node_pools", + "minimum", + "maximum", + "availability_zones", + "maintenance", + "network", + "sna_enabled", + "network_area_key", + "sna_network_prefix_length", + "platform_kubernetes_kube_config_override" + ], "assertions": [ "valid_dialogue", "reviewed_topic_reference", + "complete_native_field_overview", "no_changes" ] } @@ -5346,6 +5363,20 @@ "no_premature_saved_claim" ] }, + { + "id": "application-access-literal-permissions-append", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "applicationAccess": "literal-append", + "message": "Ergänze ausschließlich das accessProfile der bestehenden Corporate-Vorlage central: permissions [iaas.server.delete] und automationPermissions [resource-manager.project.get]. Die bisherigen unterschiedlichen Rechte jeder Empfängerliste und den zentralen initialen Owner unverändert erhalten, nichts ersetzen oder entfernen. Nur überprüfbarer Vorschlag, nichts speichern, veröffentlichen oder ausführen.", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, { "id": "application-access-kubernetes-add", "topic": "project-templates", diff --git a/landing-zone-configurator/docs/user-guide.de.md b/landing-zone-configurator/docs/user-guide.de.md index d0254ed..2874ef5 100644 --- a/landing-zone-configurator/docs/user-guide.de.md +++ b/landing-zone-configurator/docs/user-guide.de.md @@ -49,6 +49,13 @@ nachfragen, statt Netzwerkadressen, Produktverfügbarkeit oder Zugangsdaten zu e 3. Speichere den Entwurf separat. 4. Prüfe Plan und autorisiere Apply separat, wenn ein Deployment beabsichtigt ist. +**Vorschlag verwerfen** schließt nur den aktuellen Vorschlag. Gespräch, ungespeicherter +Entwurf, gespeicherte Konfiguration und ihre Zuordnung bleiben auch nach dem Neuladen +erhalten. Dabei erfolgen weder Modellanfrage noch Speichern oder Cloud-Ausführung. +Eine verworfene Auswahl ist keine offene Rückfrage mehr; fordere bei Bedarf ausdrücklich +einen neuen Vorschlag an. **Gespräch löschen** bleibt eine eigene Aktion. Dies gilt +im Konfigurations- und im Template-Entwurfschat. + Vorschläge speichern oder veröffentlichen keine Vorlagen, vergeben keine Rollen und stellen keine Infrastruktur bereit. Der Server prüft bei der Übernahme erneut deine Berechtigung und die Entwurfsrevision. Aktualisiere und prüfe einen neuen Vorschlag, wenn diff --git a/landing-zone-configurator/docs/user-guide.md b/landing-zone-configurator/docs/user-guide.md index 4b87387..299299a 100644 --- a/landing-zone-configurator/docs/user-guide.md +++ b/landing-zone-configurator/docs/user-guide.md @@ -44,6 +44,12 @@ is missing rather than invent network addresses, product availability or credent 3. Save the draft separately. 4. Review Plan and authorize Apply separately if deployment is intended. +**Discard proposal** closes only the current proposal. It preserves the conversation, +unsaved draft, saved configuration and its binding, including after reloading. No model +request, saving or cloud execution is involved. A discarded selection is no longer an +open follow-up; ask explicitly if you want a new proposal. **Delete conversation** +remains a separate action. This applies in the configuration and template-draft chat. + Proposals do not save, publish templates, grant roles or provision infrastructure. The server checks your authorization and the draft revision again on acceptance. Refresh and review a new proposal if the underlying configuration has changed.