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
26 changes: 12 additions & 14 deletions docs/cui-marking.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,19 @@ Example: `list packages --json` would otherwise write `packages.json`.
| Cover response | Meaning | Outcome |
|---|---|---|
| **403** | Feature flag disabled | `packages.json` |
| **204** | Team has CUI disabled | `packages.json` |
| **200**, no categories | Marking applies, unclassified | `Unclassified - packages.json` |
| **200**, with categories | Marking applies, classified | `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` |
| Unexpected response | Fail closed | Nothing written; the command errors |
| **200** | Classified | `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` |
| Any other response, including **204** | Fail closed | Nothing written; the command errors |

**403** and **204** both leave the artifact unmarked. They are not the same as **200 with no categories**, which still renames it to `Unclassified - …`.
The status code alone decides the outcome. Marked content is always classified: there is no unclassified artifact. Any other answer, whether a **204**, an unexpected status, a transport failure, or a **200** without a usable cover page, aborts the command and leaves no output behind.

## Scope: how the write is triggered

| Trigger | Commands | Example (unclassified) | Example (classified) |
|---|---|---|---|
| `--json` listings and reports | `list spaces`, `list packages`, `list assets` / `assignments` / `data-pools`, `config *`, `t2tc package list` / `diff`, `deployment *`, `asset-registry *` | `list packages --json` → `Unclassified - packages.json` | `list packages --json` → `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` |
| `-o, --outputToJsonFile` reports | `analyze` / `import action-flows`, `export data-pool`, `import data-pools`, `t2tc package import` report | `export data-pool -o` → `Unclassified - <report>.json` | `export data-pool -o` → `CUI - <report>.zip` containing the JSON and `CUI_Cover_Sheet.pdf` |
| Artifact is already an archive | `config package export --zip`, `config branch export --zip`, `t2tc package export`, `export action-flows`, `pull package` | `config package export --zip` → `Unclassified - my-package.zip` | `config package export --zip` → `CUI - my-package.zip` with `CUI_Cover_Sheet.pdf` inside the archive |
| Single non-archive export | `pull asset` / `skill` / `data-pool` / `view-bookmarks` / `bookmarks`, `export bookmarks` | `pull asset` → `Unclassified - asset_<key>.yml` | `pull asset` → `CUI - asset_<key>.zip` containing the YAML and `CUI_Cover_Sheet.pdf` |
| Output is a directory | `config package export`, `config branch export`, `t2tc package export --unzip` | `config package export` → `Unclassified - my-package/` | `config package export` → `CUI - my-package/` with `CUI_Cover_Sheet.pdf` inside |
| `--gitBranch` variants | `config package export`, `config branch export`, `t2tc package export` | Out of scope | Out of scope |
| No output flag | Console-only listings, profile / git-profile / log files | Out of scope | Out of scope |
| Trigger | Commands | Example when classified |
|---|---|---|
| `--json` listings and reports | `list spaces`, `list packages`, `list assets` / `assignments` / `data-pools`, `config *`, `t2tc package list` / `diff`, `deployment *`, `asset-registry *` | `list packages --json` → `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` |
| `-o, --outputToJsonFile` reports | `analyze` / `import action-flows`, `export data-pool`, `import data-pools`, `t2tc package import` report | `export data-pool -o` → `CUI - <report>.zip` containing the JSON and `CUI_Cover_Sheet.pdf` |
| Artifact is already an archive | `config package export --zip`, `config branch export --zip`, `t2tc package export`, `export action-flows`, `pull package` | `config package export --zip` → `CUI - my-package.zip` with `CUI_Cover_Sheet.pdf` inside the archive |
| Single non-archive export | `pull asset` / `skill` / `data-pool` / `view-bookmarks` / `bookmarks`, `export bookmarks` | `pull asset` → `CUI - asset_<key>.zip` containing the YAML and `CUI_Cover_Sheet.pdf` |
| Output is a directory | `config package export`, `config branch export`, `t2tc package export --unzip` | `config package export` → `CUI - my-package/` with `CUI_Cover_Sheet.pdf` inside |
| `--gitBranch` variants | `config package export`, `config branch export`, `t2tc package export` | Out of scope |
| No output flag | Console-only listings, profile / git-profile / log files | Out of scope |
22 changes: 12 additions & 10 deletions src/core/utils/cui-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { FatalError, logger } from "./logger";
import { Context } from "../command/cli-context";

export interface CuiPdfCoverResponse {
resolvedCuiMarking?: { categories?: unknown[] };
coverPage?: { pdfContent: string; encoding: string };
}

export enum CuiMarking {
DISABLED = "DISABLED",
CLASSIFIED = "CLASSIFIED",
}

export type CuiMarkingDecision =
| { marking: CuiMarking.DISABLED }
| { marking: CuiMarking.CLASSIFIED; cover: CuiPdfCoverResponse };

export class CuiApi {
private static readonly CUI_PDF_COVER_SHEET_URL = "/api/team/cui-settings/cui-pdf-cover";

private static readonly STATUS_OK = 200;
private static readonly STATUS_NO_CONTENT = 204;
private static readonly STATUS_FORBIDDEN = 403;

private readonly httpClient: () => HttpClient;
Expand All @@ -20,21 +27,16 @@ export class CuiApi {
this.httpClient = () => context.httpClient;
}

public async getCuiPdfCover(): Promise<CuiPdfCoverResponse | null> {
public async getCuiMarking(): Promise<CuiMarkingDecision> {
const { status, data } = await this.httpClient().getStatusAndData(CuiApi.CUI_PDF_COVER_SHEET_URL);

if (status === CuiApi.STATUS_FORBIDDEN) {
logger.debug("CUI marking does not apply, the feature flag is disabled");
return null;
}

if (status === CuiApi.STATUS_NO_CONTENT) {
logger.debug("CUI marking does not apply, the team has CUI disabled");
return null;
return { marking: CuiMarking.DISABLED };
}

if (status === CuiApi.STATUS_OK && data) {
return data as CuiPdfCoverResponse;
return { marking: CuiMarking.CLASSIFIED, cover: data as CuiPdfCoverResponse };
}

throw new FatalError("Problem fetching cui pdf cover");
Expand Down
20 changes: 4 additions & 16 deletions src/core/utils/cui-file-service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import * as path from "node:path";
import AdmZip = require("adm-zip");
import { Context } from "../command/cli-context";
import { CuiApi, CuiPdfCoverResponse } from "./cui-api";
import { CuiApi, CuiMarking, CuiPdfCoverResponse } from "./cui-api";
import { fileService } from "./file-service";
import { FileConstants } from "./file.constants";
import { FatalError } from "./logger";

export class CuiFileService {
public static readonly COVER_SHEET_FILE_NAME = "CUI_Cover_Sheet.pdf";
public static readonly CLASSIFIED_PREFIX = "CUI - ";
public static readonly UNCLASSIFIED_PREFIX = "Unclassified - ";

private static readonly BASE64_ENCODING = "base64";

Expand Down Expand Up @@ -60,21 +59,14 @@ export class CuiFileService {
filename: string,
onClassified: (cover: CuiPdfCoverResponse) => string
): Promise<string> {
const cover = await this.cuiApi.getCuiPdfCover();
const decision = await this.cuiApi.getCuiMarking();

if (!cover) {
if (decision.marking === CuiMarking.DISABLED) {
write(filename);
return filename;
}

if (!this.isClassified(cover)) {
const unclassifiedName = this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX);
write(unclassifiedName);

return unclassifiedName;
}

return onClassified(cover);
return onClassified(decision.cover);
}

private writeClassifiedArchive(filename: string, data: string, cover: CuiPdfCoverResponse): string {
Expand Down Expand Up @@ -113,10 +105,6 @@ export class CuiFileService {
return Buffer.from(coverPage.pdfContent, CuiFileService.BASE64_ENCODING);
}

private isClassified(cover: CuiPdfCoverResponse): boolean {
return (cover.resolvedCuiMarking?.categories?.length ?? 0) > 0;
}

private buildClassifiedArchiveName(filename: string): string {
const baseName = path.basename(filename);
const nameWithoutExtension = baseName.slice(0, baseName.length - path.extname(baseName).length);
Expand Down
1 change: 0 additions & 1 deletion tests/commands/cui-marking-directory-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const BRANCH = "feature-a";

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand Down
12 changes: 10 additions & 2 deletions tests/commands/cui-marking-json-commands.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { resolve } from "node:path";
import { readFileSync } from "node:fs";
import AdmZip = require("adm-zip");
import { mockAxiosGet, mockAxiosGetWithStatus, mockAxiosPost } from "../utls/http-requests-mock";
import { mockAxiosGet, mockAxiosGetError, mockAxiosGetWithStatus, mockAxiosPost } from "../utls/http-requests-mock";
import { testContext } from "../utls/test-context";
import { loggingTestTransport } from "../jest.setup";
import { FileService } from "../../src/core/utils/file-service";
import { CuiFileService } from "../../src/core/utils/cui-file-service";
import { FatalError } from "../../src/core/utils/logger";
import { ConfigUtils } from "../utls/config-utils";
import { zipToTempFolder } from "../utls/fs-utils";
import { DeploymentService } from "../../src/commands/deployment/deployment.service";
Expand All @@ -24,7 +25,6 @@ const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet");

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand Down Expand Up @@ -64,6 +64,14 @@ describe("CUI marking of --json commands", () => {
expect(markedPayload()).toEqual(targets);
});

it("Should fail the command without writing anything when the cover call fails", async () => {
mockAxiosGetError(COVER_URL, 500, { message: "boom" });
mockAxiosGet("https://myTeam.celonis.cloud/pacman/api/deployments/targets?deployableType=app-package&packageKey=package-key", []);

await expect(new DeploymentService(testContext).getTargets(true, "app-package", "package-key")).rejects.toThrow(FatalError);
expect(loggingTestTransport.logMessages.some(entry => entry.message.includes(FileService.fileDownloadedMessage))).toBe(false);
});

it("Should mark configuration node listings", async () => {
const nodes = [{ id: "node-id-1", key: "node-key-1", name: "Node 1" }];
mockAxiosGet("https://myTeam.celonis.cloud/pacman/api/core/packages/package-key/nodes?version=1.0.0&withConfiguration=false&limit=10", nodes);
Expand Down
1 change: 0 additions & 1 deletion tests/commands/cui-marking-output-to-json-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const POOL_ID = "pool-1";

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand Down
1 change: 0 additions & 1 deletion tests/commands/cui-marking-single-file-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const PACKAGE_KEY = "my-package";

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand Down
1 change: 0 additions & 1 deletion tests/commands/cui-marking-zip-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ const T2TC_DOWNLOAD_MESSAGE = "File downloaded successfully. New filename: ";

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand Down
13 changes: 5 additions & 8 deletions tests/commands/studio/list-cui-marking.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { resolve } from "node:path";
import { readFileSync } from "node:fs";
import AdmZip = require("adm-zip");
import { mockAxiosGet, mockAxiosGetWithStatus, mockedAxiosInstance } from "../../utls/http-requests-mock";
import { mockAxiosGet, mockAxiosGetError, mockAxiosGetWithStatus, mockedAxiosInstance } from "../../utls/http-requests-mock";
import { SpaceCommandService } from "../../../src/commands/studio/command-service/space-command.service";
import { PackageCommandService } from "../../../src/commands/studio/command-service/package-command.service";
import { testContext } from "../../utls/test-context";
Expand All @@ -20,7 +20,6 @@ const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet");

function classifiedCover(): object {
return {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
};
}
Expand Down Expand Up @@ -68,13 +67,12 @@ describe("CUI marking of Studio listings", () => {
expect(payloadFromArchive(filename)).toEqual(SPACES);
});

it("Should keep the original filename when no marking applies", async () => {
mockAxiosGetWithStatus(COVER_URL, 204, "");
it("Should keep the original filename when the feature flag is disabled", async () => {
mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" });

await listSpaces();

const filename = loggedFileName();
expect(filename.startsWith(CuiFileService.UNCLASSIFIED_PREFIX)).toBe(false);
expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(false);
expect(readWrittenJson(filename)).toEqual(SPACES);
});
Expand Down Expand Up @@ -105,13 +103,12 @@ describe("CUI marking of Studio listings", () => {
expect(payloadFromArchive(filename)).toEqual(LISTED_PACKAGES);
});

it("Should keep the original filename when no marking applies", async () => {
mockAxiosGetWithStatus(COVER_URL, 204, "");
it("Should keep the original filename when the feature flag is disabled", async () => {
mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" });

await listPackages();

const filename = loggedFileName();
expect(filename.startsWith(CuiFileService.UNCLASSIFIED_PREFIX)).toBe(false);
expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(false);
expect(readWrittenJson(filename)).toEqual(LISTED_PACKAGES);
});
Expand Down
Loading
Loading