Skip to content

SP-1173: add CUI marking for files the CLI writes to disk - #405

Draft
Dennis Woditsch (dwoditsch) wants to merge 5 commits into
mainfrom
feat/SP-1173-cui-marking-data
Draft

SP-1173: add CUI marking for files the CLI writes to disk#405
Dennis Woditsch (dwoditsch) wants to merge 5 commits into
mainfrom
feat/SP-1173-cui-marking-data

Conversation

@dwoditsch

@dwoditsch Dennis Woditsch (dwoditsch) commented Aug 7, 2026

Copy link
Copy Markdown

Description

Every user-facing file content-cli writes to disk has to carry the team's CUI (Controlled Unclassified Information) marking. This PR adds the mechanism and wires up the first two commands; the remaining writers follow in later PRs against the same service.

Architecture. Two classes, layered so each has one job, and neither is specific to the commands wired up here:

  • CuiService owns the API contract. It calls GET /api/team/cui-settings/cui-pdf-cover and turns the response into a decision. Enablement is derived from the status code, so the CLI translates what the backend says rather than deciding entitlement itself.
  • CuiFileService turns that decision into a filename and a write, delegating all disk I/O to the existing stateless fileService. One place knows about CUI, one place touches the filesystem.

Call sites stay thin. A writer builds its payload, hands it to CuiFileService, and logs whichever filename comes back, so adding the next command is a one-line swap from fileService to CuiFileService.

States. The status code decides whether marking applies at all, and the body decides how it is marked:

flowchart TD
    write["a command writes a file"] --> cover["GET /api/team/cui-settings/cui-pdf-cover"]
    cover --> status{"response"}
    status -->|"403, feature flag disabled"| plain["original name<br/>no marking applies"]
    status -->|"204, CUI disabled for the team"| plain
    status -->|"200, no categories"| unclassified["'Unclassified - name'<br/>rename only"]
    status -->|"200, with categories"| classified["'CUI - name.zip'<br/>payload + CUI_Cover_Sheet.pdf"]
    status -->|"anything else"| fatal["FatalError<br/>no file written"]
Loading
  • 403, the feature flag is disabled: original filename, nothing else changes.
  • 204, the team has CUI disabled: original filename. Same outcome as 403, kept as a separate branch so the debug log says which of the two it was.
  • 200, empty categories: unclassified, so rename only, prefixed Unclassified - . No archive and no cover sheet, since there is nothing to cover.
  • 200, with categories: classified, so the payload is wrapped into CUI - <name>.zip alongside CUI_Cover_Sheet.pdf, decoded from the base64 coverPage in the response.
  • Any other status: FatalError, and no file is written. A 500 or an unexpected code means the marking could not be resolved, which is not the same as knowing that no marking applies.

Commands that only print to the console never probe the endpoint, because nothing reaches disk.

Scope: how the write is triggered

Grouped by what makes the command produce a file, since that determines how marking has to be applied.

Trigger Commands Artifact CUI marking
--json list spaces, list packages <uuid>.json This PR
--json list assets, list assignments, list data-pools, config list/diff/validate, config metadata export, config package list/validate/import, config versions get/create, config variables list, config nodes *, config branch *, t2tc package list/diff, deployment *, asset-registry * <uuid>.json, some named, e.g. config_validate_report_<uuid>.json To follow
-o, --outputToJsonFile analyze action-flows, import action-flows, export data-pool, import data-pools named JSON, e.g. action-flows_metadata_<uuid>.json, batch_import_report_<uuid>.json To follow
--zip config package export, config branch export <packageKey>.zip, single package To follow, already a zip
no flag, the command is an export t2tc package export (and deprecated config export), export action-flows, pull package (deprecated) export_<uuid>.zip (batch archive, manifest plus one nested zip per package), action-flows_export_<uuid>.zip, package_<key>.zip To follow, already a zip
no flag, the command is an export pull asset, pull skill, pull bookmarks, pull view-bookmarks, pull data-pool single .yml / .json To follow
no flag or --unzip, output is a directory config package export, config branch export, t2tc package export --unzip directory tree of JSON, no single file Needs a decision
no output flag any listing without one nothing written, console only Out of scope
n/a profile, git profile and log files in the user home directory credentials and logs Out of scope

Two things worth settling before the follow-ups:

For the rows that already produce a zip, marking should add CUI_Cover_Sheet.pdf into the existing archive and rename it, not nest a zip inside a zip. CuiFileService currently takes a serialized string and builds the archive itself, so it needs a second entry point that merges into an archive it is handed.

For the row that writes a directory tree, there is no single file to rename. Options are to drop the cover sheet into the directory, prefix the directory name, or force a zip when marking applies.

Supporting changes. HttpClient.getStatusAndData exposes the status code, because the existing get() throws on 4xx and would hide the 403 and 204 signals that carry the meaning here. BaseManager.findAll now awaits onFindAll so an async listing callback finishes before the command resolves; that method also moved from a new Promise(...) wrapper to plain async/await.

Rollout. The cover endpoint is allowlisted in policy-guard for staging only, via celonis/policy-guard#127, which changed staging/policies/service-policies/team.rego. Until the matching production rule lands, the gateway answers 403 there and every writer takes the unmarked path, so this ships inert in production rather than breaking anything.

Relevant links

Checklist

  • I have self-reviewed this PR
  • I have tested the change and proved that it works in different scenarios
  • I have updated docs if needed

Route the JSON output of `list spaces` and `list packages` through a new
CuiFileService, which asks the team's CUI settings how the content is
classified and names the output accordingly: a `CUI - <name>.zip` holding
the listing plus the decoded cover sheet when categories apply, an
`Unclassified - <name>` rename when none do, and the original filename
when no marking applies at all.

CuiService owns the API contract and derives enablement from the response
status, so the CLI translates the backend's answer rather than deciding
entitlement itself. HttpClient.getStatusAndData exposes the status code
because get() throws on 4xx and would otherwise hide the 204/403 signal.

BaseManager.findAll now awaits onFindAll so an async listing callback
completes before the command resolves.

Includes-AI-Code: true
Co-authored-by: Cursor <cursoragent@cursor.com>
@dwoditsch Dennis Woditsch (dwoditsch) changed the title SP-1173: mark CUI content when writing Studio listings to disk SP-1173: add CUI marking for files the CLI writes to disk Aug 7, 2026
Once findAll moved to async/await, return Promise.reject() was equivalent
to throwing undefined: Sonar flags it, and the command handler logged
"undefined" instead of the real cause. Rethrow the original error.

Includes-AI-Code: true
Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the 403 branch from CuiService. Any status other than 200 or 204 now
raises a FatalError, so no file is written when the marking cannot be
resolved.

Also flatten the nested template literal in the error path, which Sonar flags.

Includes-AI-Code: true
Co-authored-by: Cursor <cursoragent@cursor.com>
403 means the feature flag is disabled, 204 means the team has CUI disabled.
Both keep the original filename, as separate branches so the debug log says
which one applied. Any other non-200 status still fails closed.

Includes-AI-Code: true
Co-authored-by: Cursor <cursoragent@cursor.com>
CuiPdfCoverResponse is the only type consumed outside the module, so the three
helper interfaces are inlined into it. Categories are counted, never read, so
their element type carries no weight.

Includes-AI-Code: true
Co-authored-by: Cursor <cursoragent@cursor.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 New Minor Issues (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant