Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function registerApplications(
| "prepareJob"
| "decideOrder"
| "deleteOrder"
| "deleteTemplate"
| "configureExecution"
| "listExecutionBindings"
| "executionCapabilities"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 }
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type VersionRow = {
published_by: string;
published_at: Date;
retired_at?: Date | null;
can_delete?: boolean;
deployment_policy: NonNullable<ApplicationInstance["deploymentPolicy"]>;
accelerator_revision: string;
document: unknown;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -374,7 +376,7 @@ export class Applications {
listTemplates(session: Session) {
return this.work(session, "read", async (client) => {
const result = await client.query<VersionRow>(
"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);
});
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ const errors: Record<string, string> = {
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:
Expand Down Expand Up @@ -1703,6 +1707,8 @@ export function Applications({
const [instances, setInstances] = useState<ApplicationInstance[]>([]);
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);
Expand Down Expand Up @@ -1797,13 +1803,15 @@ 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),
})
.parse(catalogue);
setVersions(published.versions);
setRetirementEnabled(published.retirementEnabled);
setTemplateDeletionEnabled(published.templateDeletionEnabled);
setDeploymentPolicyEnabled(published.deploymentPolicyEnabled);
setGroupAccessEnabled(published.groupAccessEnabled);
setAppliedPlatformsEnabled(published.appliedPlatformsEnabled);
Expand Down Expand Up @@ -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" },
]
Expand All @@ -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,
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2133,6 +2147,7 @@ export function Applications({
);
await load();
setConfirmRetirement(false);
setActiveTab("archive");
setNotice(
formatMessage("{{value0}} · Version {{value1}} stillgelegt.", {
value0: selected.template.name,
Expand All @@ -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<string, unknown>,
Expand Down Expand Up @@ -2969,30 +3024,34 @@ export function Applications({
</section>
)}
<section
id="application-panel-catalogue"
id={`application-panel-${archived ? "archive" : "catalogue"}`}
role="tabpanel"
aria-labelledby="application-tab-catalogue"
hidden={selectedTab !== "catalogue"}
aria-labelledby={`application-tab-${archived ? "archive" : "catalogue"}`}
hidden={selectedTab !== "catalogue" && !archived}
>
<h2>{t("Application Landing Zone Templates")}</h2>
<p>
{t(
"Wähle eine veröffentlichte Template-Version. Eine Bestellung speichert einen Auftrag; Cloud-Ressourcen werden dabei nicht erzeugt.",
archived
? "Stillgelegte Template-Versionen sind für neue Bestellungen gesperrt. Bestehende Bestellungen bleiben erhalten."
: "Wähle eine veröffentlichte Template-Version. Eine Bestellung speichert einen Auftrag; Cloud-Ressourcen werden dabei nicht erzeugt.",
)}
</p>
{loaded && !versions.length && (
{loaded && !visibleVersions.length && (
<p className="muted">
{t(
"Noch keine veröffentlichten Application Landing Zone Templates.",
archived
? "Keine stillgelegten Template-Versionen im Archiv."
: "Noch keine veröffentlichten Application Landing Zone Templates.",
)}
</p>
)}
{!!versions.length && (
{!!visibleVersions.length && (
<fieldset className="application-template-catalog">
<legend className="sr-only">
{t("Veröffentlichte Application Landing Zone Templates")}
</legend>
{versions.map((item) => (
{visibleVersions.map((item) => (
<button
type="button"
className="application-template-choice"
Expand Down Expand Up @@ -3049,7 +3108,7 @@ export function Applications({
))}
</fieldset>
)}
{canPublish && groupAccessEnabled && selected && (
{canPublish && groupAccessEnabled && selected && !archived && (
<fieldset>
<legend>{t("Freigaben dieser Template-Version")}</legend>
{applicationGroups.groups.map((group) => (
Expand Down Expand Up @@ -3107,10 +3166,51 @@ export function Applications({
{selected.template.name} · {t("Version")} {selected.version}
</h3>
{selected.retiredAt ? (
<p>
{t("Stillgelegt")} ·{" "}
{new Date(selected.retiredAt).toLocaleString(currentLanguage())}
</p>
<div>
<p>
{t("Stillgelegt")} ·{" "}
{new Date(selected.retiredAt).toLocaleString(
currentLanguage(),
)}
</p>
{templateDeletionEnabled &&
(selected.canDelete ? (
<>
<p>
{t(
"Löschen entfernt diese Version aus dem Archiv. Der Veröffentlichungs- und Löschnachweis bleibt erhalten.",
)}
</p>
<label className="toggle-label">
<input
type="checkbox"
checked={confirmTemplateDeletion}
disabled={busy}
onChange={(event) =>
setConfirmTemplateDeletion(event.target.checked)
}
/>
{t(
"Diese unbenutzte Template-Version aus dem Archiv löschen",
)}
</label>
<button
type="button"
className="button"
disabled={busy || !confirmTemplateDeletion}
onClick={() => void deleteTemplate()}
>
{t("Template-Version löschen")}
</button>
</>
) : (
<p>
{t(
"Diese Version kann nicht gelöscht werden, wenn sie bereits bestellt wurde. Auch gelöschte oder archivierte Bestellungen zählen als Verwendung.",
)}
</p>
))}
</div>
) : (
<>
<label className="toggle-label">
Expand Down
Loading
Loading