diff --git a/landing-zone-configurator/app/apps/api/db/062_application_template_deletions.sql b/landing-zone-configurator/app/apps/api/db/062_application_template_deletions.sql new file mode 100644 index 0000000..c4bf668 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/062_application_template_deletions.sql @@ -0,0 +1,54 @@ +-- Catalogue deletion retains immutable publication evidence and version numbers. +-- A historical order, including a deleted/archived one, permanently prevents it. +CREATE TABLE lzc.application_template_deletions ( + tenant_id uuid NOT NULL REFERENCES lzc.tenants(id), + version_id uuid NOT NULL, + deleted_by uuid NOT NULL REFERENCES lzc_auth.users(id), + deleted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(tenant_id,version_id), + FOREIGN KEY(tenant_id,version_id) REFERENCES lzc.application_template_retirements(tenant_id,version_id) +); +ALTER TABLE lzc.application_template_deletions ENABLE ROW LEVEL SECURITY; +ALTER TABLE lzc.application_template_deletions FORCE ROW LEVEL SECURITY; +CREATE POLICY application_template_deletion_read ON lzc.application_template_deletions FOR SELECT TO configurator_app + USING(tenant_id=lzc.current_tenant_id() AND (lzc.application_role('platform-engineer') OR lzc.application_role('application-owner'))); +CREATE POLICY application_template_deletion_migration ON lzc.application_template_deletions TO configurator_migration USING(true) WITH CHECK(true); +GRANT SELECT ON lzc.application_template_deletions TO configurator_app; +CREATE TRIGGER immutable_application_template_deletion BEFORE UPDATE OR DELETE ON lzc.application_template_deletions + FOR EACH ROW EXECUTE FUNCTION lzc.immutable_application_version(); + +CREATE FUNCTION lzc.application_template_deletable(p_version uuid) RETURNS boolean +LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$ + SELECT lzc.application_role('platform-engineer') AND EXISTS( + SELECT 1 FROM lzc.application_template_retirements r + WHERE r.tenant_id=lzc.current_tenant_id() AND r.version_id=p_version + AND NOT EXISTS(SELECT 1 FROM lzc.application_template_deletions d WHERE d.tenant_id=r.tenant_id AND d.version_id=r.version_id) + AND NOT EXISTS(SELECT 1 FROM lzc.application_instances i WHERE i.tenant_id=r.tenant_id AND i.version_id=r.version_id) + ); +$$; +REVOKE ALL ON FUNCTION lzc.application_template_deletable(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc.application_template_deletable(uuid) TO configurator_app; + +CREATE FUNCTION lzc_auth.delete_application_template(p_session uuid,p_version uuid) +RETURNS lzc.application_template_deletions LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE deleted lzc.application_template_deletions; +BEGIN + PERFORM lzc_auth.authorize_application(p_session,lzc.current_tenant_id(),'publish'); + -- Same lock as ordering and retirement; no check/delete race with a new order. + PERFORM pg_advisory_xact_lock(hashtextextended('application-availability:' || lzc.current_tenant_id()::text || ':' || p_version::text,0)); + PERFORM 1 FROM lzc.application_template_versions WHERE tenant_id=lzc.current_tenant_id() AND id=p_version FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'application_access_denied' USING ERRCODE='42501'; END IF; + SELECT * INTO deleted FROM lzc.application_template_deletions WHERE tenant_id=lzc.current_tenant_id() AND version_id=p_version; + IF FOUND THEN RETURN deleted; END IF; + IF NOT EXISTS(SELECT 1 FROM lzc.application_template_retirements WHERE tenant_id=lzc.current_tenant_id() AND version_id=p_version) THEN + RAISE EXCEPTION 'template_version_not_retired' USING ERRCODE='40001'; + END IF; + IF EXISTS(SELECT 1 FROM lzc.application_instances WHERE tenant_id=lzc.current_tenant_id() AND version_id=p_version) THEN + RAISE EXCEPTION 'template_version_in_use' USING ERRCODE='40001'; + END IF; + INSERT INTO lzc.application_template_deletions(tenant_id,version_id,deleted_by) + VALUES(lzc.current_tenant_id(),p_version,lzc.current_user_id()) RETURNING * INTO deleted; + RETURN deleted; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.delete_application_template(uuid,uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.delete_application_template(uuid,uuid) TO configurator_app; diff --git a/landing-zone-configurator/app/apps/api/src/applications/routes.ts b/landing-zone-configurator/app/apps/api/src/applications/routes.ts index 0daf90a..8747a33 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/routes.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/routes.ts @@ -137,6 +137,7 @@ export function registerApplications( | "prepareJob" | "decideOrder" | "deleteOrder" + | "deleteTemplate" | "configureExecution" | "listExecutionBindings" | "executionCapabilities" @@ -179,6 +180,8 @@ export function registerApplications( "application_order_deleted", "application_order_execution_started", "application_order_archive_unavailable", + "template_version_in_use", + "template_version_not_retired", "application_execution_binding_conflict", "application_apply_plan_unavailable", "application_apply_package_changed", @@ -356,6 +359,7 @@ export function registerApplications( return { versions: await applications.listTemplates(session), retirementEnabled: true, + templateDeletionEnabled: Boolean(applications.deleteTemplate), deploymentPolicyEnabled: true, ...(applications.appliedPlatformsEnabled?.() ? { appliedPlatformsEnabled: true } @@ -379,6 +383,16 @@ export function registerApplications( return applications.retire(session, id, request.body); }, ); + if (applications.deleteTemplate) + routes.post( + "/api/v1/applications/templates/:id/delete", + async (request, reply) => { + const session = sessions.get(request); + if (!session) return reply.code(401).send(); + const { id } = z.object({ id: z.uuid() }).parse(request.params); + return applications.deleteTemplate?.(session, id, request.body); + }, + ); routes.get("/api/v1/applications/instances", async (request, reply) => { const session = sessions.get(request); if (!session) return reply.code(401).send(); diff --git a/landing-zone-configurator/app/apps/api/src/applications/service.ts b/landing-zone-configurator/app/apps/api/src/applications/service.ts index 4f38899..ed458eb 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/service.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/service.ts @@ -76,6 +76,7 @@ type VersionRow = { published_by: string; published_at: Date; retired_at?: Date | null; + can_delete?: boolean; deployment_policy: NonNullable; accelerator_revision: string; document: unknown; @@ -144,6 +145,7 @@ function version(row: VersionRow): PublishedProjectTemplate { ? { allowedGroupIds: row.allowed_group_ids } : {}), ...(row.retired_at ? { retiredAt: row.retired_at.toISOString() } : {}), + ...(row.can_delete !== undefined ? { canDelete: row.can_delete } : {}), acceleratorRevision: row.accelerator_revision, platformRevision: row.platform_revision, targetKey: row.target_key, @@ -374,7 +376,7 @@ export class Applications { listTemplates(session: Session) { return this.work(session, "read", async (client) => { const result = await client.query( - "SELECT v.*,r.retired_at,ARRAY(SELECT access.group_id FROM lzc.application_template_groups access WHERE access.tenant_id=v.tenant_id AND access.version_id=v.id ORDER BY access.group_id) AS allowed_group_ids FROM lzc.application_template_versions v LEFT JOIN lzc.application_template_retirements r ON r.tenant_id=v.tenant_id AND r.version_id=v.id WHERE r.version_id IS NULL OR lzc.application_role('platform-engineer') ORDER BY v.published_at DESC,v.id DESC LIMIT 200", + "SELECT v.*,r.retired_at,lzc.application_template_deletable(v.id) AS can_delete,ARRAY(SELECT access.group_id FROM lzc.application_template_groups access WHERE access.tenant_id=v.tenant_id AND access.version_id=v.id ORDER BY access.group_id) AS allowed_group_ids FROM lzc.application_template_versions v LEFT JOIN lzc.application_template_retirements r ON r.tenant_id=v.tenant_id AND r.version_id=v.id WHERE NOT EXISTS(SELECT 1 FROM lzc.application_template_deletions d WHERE d.tenant_id=v.tenant_id AND d.version_id=v.id) AND (r.version_id IS NULL OR lzc.application_role('platform-engineer')) ORDER BY v.published_at DESC,v.id DESC LIMIT 200", ); return result.rows.map(version); }); @@ -388,6 +390,27 @@ export class Applications { await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))", [ `application-availability:${session.tenantId}:${versionId}`, ]); + const deleted = await client.query( + "SELECT 1 FROM lzc.application_template_deletions WHERE tenant_id=$1 AND version_id=$2", + [session.tenantId, versionId], + ); + if (deleted.rowCount) + throw new ApplicationError(404, "template_version_not_found"); + } + + deleteTemplate(session: Session, versionId: string, input: unknown) { + z.uuid().parse(versionId); + assertBoundedJson(input); + z.strictObject({ confirmDeletion: z.literal(true) }).parse(input); + return this.work(session, "publish", async (client) => { + const result = await client.query<{ deleted_at: Date }>( + "SELECT (lzc_auth.delete_application_template($1,$2)).deleted_at", + [session.id, versionId], + ); + const receipt = result.rows[0]; + if (!receipt) throw new ApplicationError(503, "template_deletion_failed"); + return { versionId, deletedAt: receipt.deleted_at.toISOString() }; + }); } listGroups(session: Session) { diff --git a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx index 53781cf..1ef2a30 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx @@ -119,6 +119,10 @@ const errors: Record = { template_version_not_found: "Diese Template-Version ist in deinem Arbeitsbereich nicht verfügbar.", template_version_retired: "Diese Template-Version wurde stillgelegt.", + template_version_in_use: + "Diese Template-Version wurde bereits bestellt und kann nicht gelöscht werden.", + template_version_not_retired: + "Lege diese Template-Version vor dem Löschen still.", template_retirement_failed: "Die Template-Version konnte nicht stillgelegt werden.", idempotency_conflict: @@ -1703,6 +1707,8 @@ export function Applications({ const [instances, setInstances] = useState([]); const [versionId, setVersionId] = useState(""); const [retirementEnabled, setRetirementEnabled] = useState(false); + const [templateDeletionEnabled, setTemplateDeletionEnabled] = useState(false); + const [confirmTemplateDeletion, setConfirmTemplateDeletion] = useState(false); const [deploymentPolicyEnabled, setDeploymentPolicyEnabled] = useState(false); const [orderDecisionEnabled, setOrderDecisionEnabled] = useState(false); const [orderDeletionEnabled, setOrderDeletionEnabled] = useState(false); @@ -1797,6 +1803,7 @@ export function Applications({ .object({ versions: z.array(publishedProjectTemplateSchema).max(200), retirementEnabled: z.boolean().default(false), + templateDeletionEnabled: z.boolean().default(false), deploymentPolicyEnabled: z.boolean().default(false), groupAccessEnabled: z.boolean().default(false), appliedPlatformsEnabled: z.boolean().default(false), @@ -1804,6 +1811,7 @@ export function Applications({ .parse(catalogue); setVersions(published.versions); setRetirementEnabled(published.retirementEnabled); + setTemplateDeletionEnabled(published.templateDeletionEnabled); setDeploymentPolicyEnabled(published.deploymentPolicyEnabled); setGroupAccessEnabled(published.groupAccessEnabled); setAppliedPlatformsEnabled(published.appliedPlatformsEnabled); @@ -1997,6 +2005,7 @@ export function Applications({ { id: "orders", label: "Bestellungen" }, ...(canPublish ? [ + { id: "archive", label: "Archiv" }, { id: "publish", label: "Veröffentlichung" }, { id: "platform", label: "Plattformanbindung" }, ] @@ -2016,7 +2025,11 @@ export function Applications({ canPublish && draft ? projectTemplates(draft).filter((item) => item.kind !== "sandbox") : []; - const selected = versions.find((item) => item.id === versionId); + const archived = selectedTab === "archive"; + const visibleVersions = versions.filter( + (item) => Boolean(item.retiredAt) === archived, + ); + const selected = visibleVersions.find((item) => item.id === versionId); const detailVersion = versions.find( (item) => item.id === selectedInstance?.versionId, ); @@ -2049,6 +2062,7 @@ export function Applications({ function selectVersion(nextVersionId: string) { setVersionId(nextVersionId); setConfirmRetirement(false); + setConfirmTemplateDeletion(false); const version = versions.find((item) => item.id === nextVersionId); setVersionGroupIds(version?.allowedGroupIds ?? []); setConfirmGroupAccess(false); @@ -2133,6 +2147,7 @@ export function Applications({ ); await load(); setConfirmRetirement(false); + setActiveTab("archive"); setNotice( formatMessage("{{value0}} · Version {{value1}} stillgelegt.", { value0: selected.template.name, @@ -2150,6 +2165,46 @@ export function Applications({ } } + async function deleteTemplate() { + if ( + !session || + !selected?.canDelete || + !selected.retiredAt || + !canPublish || + !templateDeletionEnabled || + !confirmTemplateDeletion || + busy + ) + return; + setBusy(true); + setError(""); + setNotice(""); + try { + z.object({ versionId: z.uuid(), deletedAt: z.iso.datetime() }).parse( + await request(`templates/${selected.id}/delete`, session, { + confirmDeletion: true, + }), + ); + setVersionId(""); + setConfirmTemplateDeletion(false); + await load(); + setNotice( + formatMessage("{{value0}} · Version {{value1}} gelöscht.", { + value0: selected.template.name, + value1: selected.version, + }), + ); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Die Template-Version konnte nicht gelöscht werden.", + ); + } finally { + setBusy(false); + } + } + async function changeGroupAccess( path: string, input: Record, @@ -2969,30 +3024,34 @@ export function Applications({ )}