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
Expand Up @@ -399,7 +399,7 @@ export class StackitRecoveryCloud {
id: text,
name: text,
description: z.string().max(2000),
permissions: z.array(z.object({ name: text })).max(999),
permissions: z.array(z.object({ name: text })).max(2000),
}),
)
.max(999),
Expand Down
164 changes: 97 additions & 67 deletions landing-zone-configurator/app/apps/api/src/applications/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
publishedProjectTemplateSchema,
resolveApplicationOrder,
serializeTfvars,
unknownApplicationPermissions,
validateApplicationPublication,
} from "@lzc/domain";
import type pg from "pg";
Expand Down Expand Up @@ -226,7 +227,13 @@ export class Applications {
private readonly dispatch?: { runner: PlanRunner; origin: string },
private readonly artifactCrypto?: ArtifactCrypto,
private readonly platforms?: Pick<Plans, "outputs">,
private readonly options: { recoveryReviewsEnabled: boolean } = {
private readonly options: {
recoveryReviewsEnabled: boolean;
projectPermissions?: (
session: Session,
region: "eu01" | "eu02",
) => Promise<string[]>;
} = {
recoveryReviewsEnabled: false,
},
) {}
Expand Down Expand Up @@ -947,75 +954,98 @@ export class Applications {
} catch {
throw new ApplicationError(400, "invalid_application_request");
}
return this.work(session, "publish", async (client) => {
if (binding.platformRevision && binding.targetKey) {
const contracts = await client.query<ContractRow>(
"SELECT * FROM lzc.application_platform_contracts WHERE revision=$1",
[binding.platformRevision],
);
const row = contracts.rows[0];
if (!row)
throw new ApplicationError(404, "platform_contract_not_found");
const target = contract(row).document.targets[binding.targetKey];
if (
!target ||
target.region !== template.region ||
target.corporate !== (template.kind === "corporate")
)
throw new ApplicationError(400, "invalid_application_target");
const verifyPermissions = async () => {
if (
template.accessProfile &&
unknownApplicationPermissions(template.accessProfile).length
) {
// Catalogue lookup may open another pool connection; authorize and release first.
await this.work(session, "publish", async () => undefined);
let live: string[] = [];
try {
live =
(await this.options.projectPermissions?.(
session,
template.region,
)) ?? [];
} catch {
/* Snapshot fallback remains available. */
}
if (unknownApplicationPermissions(template.accessProfile, live).length)
throw new ApplicationError(400, "unknown_application_permissions");
}
await client.query(
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
[`application-version:${session.tenantId}:${template.id}`],
);
const latest = await client.query<VersionRow & { matches: boolean }>(
"SELECT *,document=$2::jsonb AND accelerator_revision=$3 AND platform_revision IS NOT DISTINCT FROM $4::uuid AND target_key IS NOT DISTINCT FROM $5::text AND deployment_policy=$6 AS matches FROM lzc.application_template_versions WHERE template_id=$1 ORDER BY version DESC LIMIT 1",
[
template.id,
JSON.stringify(template),
binding.acceleratorRevision,
binding.platformRevision ?? null,
binding.targetKey ?? null,
binding.deploymentPolicy,
],
);
if (latest.rows[0]?.matches) {
await this.lockVersion(client, session, latest.rows[0].id);
const retired = await client.query(
"SELECT 1 FROM lzc.application_template_retirements WHERE tenant_id=$1 AND version_id=$2",
[session.tenantId, latest.rows[0].id],
};
return verifyPermissions().then(() =>
this.work(session, "publish", async (client) => {
if (binding.platformRevision && binding.targetKey) {
const contracts = await client.query<ContractRow>(
"SELECT * FROM lzc.application_platform_contracts WHERE revision=$1",
[binding.platformRevision],
);
const row = contracts.rows[0];
if (!row)
throw new ApplicationError(404, "platform_contract_not_found");
const target = contract(row).document.targets[binding.targetKey];
if (
!target ||
target.region !== template.region ||
target.corporate !== (template.kind === "corporate")
)
throw new ApplicationError(400, "invalid_application_target");
}
await client.query(
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
[`application-version:${session.tenantId}:${template.id}`],
);
if (retired.rowCount === 0)
return this.publicationAccess(
client,
session,
latest.rows[0],
binding.allowedGroupIds,
const latest = await client.query<VersionRow & { matches: boolean }>(
"SELECT *,document=$2::jsonb AND accelerator_revision=$3 AND platform_revision IS NOT DISTINCT FROM $4::uuid AND target_key IS NOT DISTINCT FROM $5::text AND deployment_policy=$6 AS matches FROM lzc.application_template_versions WHERE template_id=$1 ORDER BY version DESC LIMIT 1",
[
template.id,
JSON.stringify(template),
binding.acceleratorRevision,
binding.platformRevision ?? null,
binding.targetKey ?? null,
binding.deploymentPolicy,
],
);
if (latest.rows[0]?.matches) {
await this.lockVersion(client, session, latest.rows[0].id);
const retired = await client.query(
"SELECT 1 FROM lzc.application_template_retirements WHERE tenant_id=$1 AND version_id=$2",
[session.tenantId, latest.rows[0].id],
);
}
const result = await client.query<VersionRow>(
"INSERT INTO lzc.application_template_versions(tenant_id,template_id,version,published_by,accelerator_revision,document,platform_revision,target_key,deployment_policy) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *",
[
session.tenantId,
template.id,
(latest.rows[0]?.version ?? 0) + 1,
session.userId,
binding.acceleratorRevision,
JSON.stringify(template),
binding.platformRevision ?? null,
binding.targetKey ?? null,
binding.deploymentPolicy,
],
);
const row = result.rows[0];
if (!row) throw new ApplicationError(503, "publication_failed");
return this.publicationAccess(
client,
session,
row,
binding.allowedGroupIds,
);
});
if (retired.rowCount === 0)
return this.publicationAccess(
client,
session,
latest.rows[0],
binding.allowedGroupIds,
);
}
const result = await client.query<VersionRow>(
"INSERT INTO lzc.application_template_versions(tenant_id,template_id,version,published_by,accelerator_revision,document,platform_revision,target_key,deployment_policy) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *",
[
session.tenantId,
template.id,
(latest.rows[0]?.version ?? 0) + 1,
session.userId,
binding.acceleratorRevision,
JSON.stringify(template),
binding.platformRevision ?? null,
binding.targetKey ?? null,
binding.deploymentPolicy,
],
);
const row = result.rows[0];
if (!row) throw new ApplicationError(503, "publication_failed");
return this.publicationAccess(
client,
session,
row,
binding.allowedGroupIds,
);
}),
);
}

private async instanceWithExecution(client: pg.PoolClient, row: InstanceRow) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Reviewed explanation of the deployed project IAM contract, never an access grant. */
export function applicationAccessOverview(language: "de" | "en") {
return language === "en"
? "Recommend the granular workload profile for new Application templates. The IDP verifies login identity; Configurator membership roles and STACKIT project IAM are separate. Apply creates the project's IAM roles. accessProfile.initialOwnerEmail selects a central user or service account who receives initial project ownership and must differ from the requester. permissions and automationPermissions define separate explicit workload roles for the Application team and its automation account; for example iaas.server.get. The workload profile covers VMs, Object Storage, Observability and Secrets Manager and excludes IAM administration, project deletion, central routing changes, privileged service-account management and direct Floating-IP attachment. The separate VM-only preset is narrower. Effective permissions and successful workload operations still require live IAM acceptance. Full project owner remains an explicit customer choice for requester and automation. Owner permits IAM/project administration, so central audit/routing controls cannot be immutable against that identity. Direct, group and inherited grants are additive: a granular binding does not cancel inherited owner rights. The Platform Engineer fixes the profile in an immutable template version; requesters cannot override it. Existing versions and instances retain their permissions. Nothing has been changed, saved, published or executed."
: "Für neue Application-Templates empfehlen wir das granulare Workload-Profil. Der IDP bestätigt die Anmeldung; Configurator-Mitgliedschaftsrollen und STACKIT-Projekt-IAM sind getrennt. Erst Apply legt die Projekt-IAM-Rollen an. accessProfile.initialOwnerEmail wählt einen zentralen Benutzer oder Service Account als initialen Projekt-Owner, der sich vom Besteller unterscheiden muss. permissions und automationPermissions definieren getrennte explizite Workload-Rollen für Application-Team und Automation, beispielsweise iaas.server.get. Die Workload-Auswahl umfasst VMs, Object Storage, Observability und Secrets Manager, jedoch keine IAM-Verwaltung, Projektlöschung, zentralen Routing-Änderungen, privilegierte Service-Account-Verwaltung oder direkte Floating-IP-Anbindung. Das separate VM-Preset bleibt enger. Effektive Rechte und erfolgreiche Workload-Operationen brauchen noch reale IAM-Abnahme. Volle Projekt-Owner-Rechte bleiben eine ausdrückliche Kundenwahl für Besteller und Automation. Owner erlaubt IAM-/Projektverwaltung; zentrale Audit-/Routing-Vorgaben sind gegenüber dieser Identität nicht unveränderlich. Direkte, gruppenbezogene und geerbte Rechte sind additiv: Eine granulare Zuweisung hebt geerbte Owner-Rechte nicht auf. Der Platform Engineer schreibt das Profil in einer unveränderlichen Template-Version fest; Besteller können es nicht überschreiben. Bestehende Versionen und Instanzen behalten ihre Rechte. Es wurde nichts geändert, gespeichert, veröffentlicht oder ausgeführt.";
? "Recommend the granular workload profile for new Application templates. The IDP verifies login identity; Configurator membership roles and STACKIT project IAM are separate. Apply creates the project's IAM roles. accessProfile.initialOwnerEmail selects a central user or service account who receives initial project ownership and must differ from the requester. permissions and automationPermissions define separate explicit workload roles for the requester and Application service account; for example iaas.server.get. The 144-permission workload DEFAULT covers VMs, Object Storage, Observability and Secrets Manager. It is not a maximum: Platform Owners may add any official STACKIT project permission, including SKE/Kubernetes, databases or deliberately privileged IAM/project administration. Full official project catalogue snapshot and available live data provide the identifiers; never invent permissions. A request to ADD service management rights preserves the existing granular profile and central owner, unions the catalogue service permissions into the requested human/automation lists (both unless distinguished), and never removes unrelated rights. SKE project IAM permits cluster management; Kubernetes RBAC inside a cluster is separate; ske.cluster.kubeconfig.create permits retrieving admin kubeconfigs and therefore cluster administrator access. IAM role/member management may enable privilege escalation; these permissions are excluded only from the default, not from customer choice. The separate VM-only preset is narrower. Effective permissions and successful workload operations still require live IAM acceptance. Full project owner remains an explicit customer choice for requester and automation. Owner permits IAM/project administration, so central audit/routing controls cannot be immutable against that identity. Direct, group and inherited grants are additive: a granular binding does not cancel inherited owner rights. The Platform Engineer fixes the profile in an immutable template version; requesters cannot override it. Existing versions and instances retain their permissions. Nothing has been changed, saved, published or executed."
: "Für neue Application-Templates empfehlen wir das granulare Workload-Profil. Der IDP bestätigt die Anmeldung; Configurator-Mitgliedschaftsrollen und STACKIT-Projekt-IAM sind getrennt. Erst Apply legt die Projekt-IAM-Rollen an. accessProfile.initialOwnerEmail wählt einen zentralen Benutzer oder Service Account als initialen Projekt-Owner, der sich vom Besteller unterscheiden muss. permissions und automationPermissions definieren getrennte explizite Workload-Rollen für Besteller und Application-Service-Account, beispielsweise iaas.server.get. Der Workload-STANDARD umfasst 144 Rechte für VMs, Object Storage, Observability und Secrets Manager. Er ist keine Obergrenze: Platform Owner können alle offiziellen STACKIT-Projektrechte ergänzen, etwa SKE/Kubernetes, Datenbanken oder bewusst privilegierte IAM-/Projektverwaltung. Der vollständige statische Projektkatalog und verfügbare Live-Daten liefern die Kennungen; keine Rechte erfinden. Beim ERGÄNZEN von Dienstverwaltungsrechten bleiben das bestehende granulare Profil und der zentrale Owner erhalten; nur die angefragten Listen von Besteller/Application-Service-Account werden um die Katalogrechte des Diensts erweitert (beide, wenn nicht unterschieden). Keine anderen Rechte entfernen. SKE-Projekt-IAM erlaubt Clusterverwaltung; Kubernetes-RBAC innerhalb eines Clusters ist davon getrennt; ske.cluster.kubeconfig.create erlaubt das Abrufen von Admin-Kubeconfigs und damit Clusteradministrator-Zugriff. IAM-Rollen-/Mitgliederverwaltung kann Rechteerweiterungen ermöglichen; solche Rechte fehlen im Standard, bleiben aber Kundenwahl. Das separate VM-Preset bleibt enger. Effektive Rechte und erfolgreiche Workload-Operationen brauchen noch reale IAM-Abnahme. Volle Projekt-Owner-Rechte bleiben eine ausdrückliche Kundenwahl für Besteller und Automation. Owner erlaubt IAM-/Projektverwaltung; zentrale Audit-/Routing-Vorgaben sind gegenüber dieser Identität nicht unveränderlich. Direkte, gruppenbezogene und geerbte Rechte sind additiv: Eine granulare Zuweisung hebt geerbte Owner-Rechte nicht auf. Der Platform Engineer schreibt das Profil in einer unveränderlichen Template-Version fest; Besteller können es nicht überschreiben. Bestehende Versionen und Instanzen behalten ihre Rechte. Es wurde nichts geändert, gespeichert, veröffentlicht oder ausgeführt.";
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util";
import { assistantProposalValue } from "@lzc/contracts";
import {
acceleratorInputs,
applicationPermissionName,
assertBoundedJson,
type CommonConfiguration,
compileCommonConfiguration,
Expand Down Expand Up @@ -590,10 +591,16 @@ export function assistantEditorChangeSchema(

export function assistantEditorResponseSchema(schema: z.ZodType) {
const result = z.toJSONSchema(schema);
const permissionPattern = z.toJSONSchema(applicationPermissionName).pattern;
// Serving lacks propertyNames; the original Zod schema still checks map keys.
const visit = (value: unknown): void => {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
if ("propertyNames" in value) delete value.propertyNames;
// Serving cannot compile the permission regex intersected with maxLength.
// Keep bounded strings here; the original Zod schema and trusted catalogue
// still validate every returned profile before a proposal is accepted.
if ("pattern" in value && value.pattern === permissionPattern)
delete value.pattern;
for (const [key, child] of Object.entries(value)) {
if (
["properties", "$defs", "definitions", "patternProperties"].includes(
Expand Down
Loading
Loading