Skip to content

cloudsec: bind the last four routes + the findings owner filter - #329

Merged
maximelb merged 5 commits into
masterfrom
solve/cloudsec-w7-api-surface-w7api
Jul 30, 2026
Merged

cloudsec: bind the last four routes + the findings owner filter#329
maximelb merged 5 commits into
masterfrom
solve/cloudsec-w7-api-surface-w7api

Conversation

@maximelb

Copy link
Copy Markdown
Contributor

What was asked

Close the remaining gaps in the cloudsec integration surface: bind the four /v1/cloudsec/*
routes the SDK/CLI did not cover (/findings/causes, /ciem/identities,
/data-security/stores, /free-tier), and expose the owner findings filter the API accepts
but no client could send.

What was done

The four unbound routes, each mirroring the API's actual parameter set (read from the
gateway handlers, not guessed) and following the existing surface's conventions — cursor/limit
paging, --output rendering, docstrings, --ai-help explain text, tests:

SDK CLI
list_finding_causes() cloudsec finding causes
list_identity_access() cloudsec ciem identities
list_data_stores() cloudsec data-security stores
get_free_tier() cloudsec free-tier

The owner filter. finding list|facets and export findings take --owner (repeatable)
and --unassigned; finding facets also takes --owner-pin. Without this, finding set-owner could assign an owner that no API caller could then filter by — the write existed
and the read did not.

The unassigned bucket is an owner value on the wire (the empty string), so both flags ride
one repeatable selector and "mine or nobody's" is a single filter with two values.
_owner_selector folds them and returns None when neither is given, so an unfiltered read
sends exactly what it sent before. --owner-pin is deliberately separate and documented as
not a filter: it keeps named owners visible in the owner facet, which is capped at the top
50 by count (owner_truncated reports drops), and selects no rows.

Two facet bindings gained their list's selectors. ciem facets and data-security facets
previously took none, while their new list siblings take a full cross-filter — and a facet
count that is computed over a different population than the list it labels is a bug waiting to
be believed. Both now share one selector set with their list. Additive: an unfiltered call
emits the same request as before (covered by a test each). The identity/store booleans are
tri-state — absent leaves the dimension unconstrained, which is not the same as pinning it
false — so every one defaults to None rather than being a plain flag, and --mfa is a
three-value choice because unknown is not off.

Files: limacharlie/sdk/cloudsec.py, limacharlie/commands/cloudsec.py, tests in
tests/unit/test_sdk_cloudsec.py + tests/unit/test_cli_cloudsec.py (+ the cloudsec
subcommand snapshot in test_cli_lazy_loading_regression.py), and the user docs
(doc/cli/cloud-security.md, doc/sdk/other-classes.md, CHANGELOG.md).

Not done, deliberately: config CRUD stays out of this surface — cloudsec_* records are
managed through the generic hive commands, as the module header has always stated.

Testing

  • Full suite: 3803 passed, 5 skipped (pytest tests/unit/ tests/microbenchmarks/ --benchmark-disable), which is what CI runs.
  • New tests cover the wire shapes rather than the plumbing: that an empty owner value survives
    as ?owner= (dropping it would silently widen the read to the whole estate), that no owner
    flags send no constraint at all, that an omitted tri-state sends nothing while an explicit
    false sends false, that a pin is not a filter, and that each new route hits the right path
    with the right selectors.
  • Live-verified read-only against an internal test org (no write verbs): all four routes
    returned their documented shapes, and the owner round-trip agrees end to end — the facet
    reported 59 unassigned plus 1 assigned within a filter scope, --unassigned returned only
    unowned rows, the assigned-owner filter returned exactly that one row, and an owner nobody
    holds returned zero, so the selector is applied rather than ignored.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L7CkEf1fEa9Y8dBn5Utrx1

The cloudsec SDK/CLI covered 44 of the 48 gateway routes. The four
unbound ones are now bound, and the owner selector the gateway has
always accepted on the findings list/facets is now expressible:

- `finding causes` / `list_finding_causes()` — the shared-fix rollup
  (findings grouped by the mutable object whose one edit resolves them),
  with `--cause` for a single exact count and `distinct` disclosing the
  tail the ranked head hides.
- `ciem identities` / `list_identity_access()` — the ranked, filtered,
  keyset-paginated identity access population.
- `data-security stores` / `list_data_stores()` — the paginated DSPM row
  list.
- `free-tier` / `get_free_tier()` — the org's tier and provider usage
  against the free-tier limits.
- `--owner` / `--unassigned` on `finding list|facets` and `export
  findings`, plus `--owner-pin` on `finding facets`. Without this,
  `finding set-owner` assigned an owner no API caller could filter by.
  The unassigned bucket IS an owner value on the wire (the empty string),
  so both ride one repeatable selector and "mine or nobody's" is one
  filter with two values; `_owner_selector` folds the flags and returns
  None when neither is given, so an unfiltered read is unchanged.

The two facet reads that pair with a new list — `ciem facets` and
`data-security facets` — gained the same cross-filter their list takes,
which is what keeps a facet count describing the population the list
returns. Both are additive: an unfiltered call sends the same request it
sent before. The identity/store boolean selectors are tri-state (absent
leaves the dimension unconstrained, which is not the same as pinning it
false), so every one defaults to None rather than being a plain flag.

Verified read-only against an internal test org: all four routes return
their documented shapes, and the owner round-trip agrees end to end —
the owner facet reported 59 unassigned plus 1 assigned for a filter
scope, `--unassigned` returned only unowned rows, the assigned-owner
filter returned exactly that one row, and an owner nobody holds returned
zero (so the selector is applied, not ignored).

Tests: 3803 passed, 5 skipped (unit + microbenchmarks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7CkEf1fEa9Y8dBn5Utrx1
lcbill
lcbill previously approved these changes Jul 30, 2026
Comment thread limacharlie/sdk/cloudsec.py Outdated
Comment thread limacharlie/commands/cloudsec.py
Comment thread limacharlie/commands/cloudsec.py Outdated
Comment thread limacharlie/commands/cloudsec.py
Comment thread limacharlie/commands/cloudsec.py Outdated
Comment thread tests/unit/test_cli_cloudsec.py Outdated
@maximelb

Copy link
Copy Markdown
Contributor Author

Verified against the gateway handlers and the backend RPC parsers: all four route bindings send exactly the parameters the API forwards, with the right names/shapes, nothing extra, nothing missing; ?owner= survives urlencode to the wire and to the backend filter; the tri-states distinguish absent from false end to end; every gateway /cloudsec/* route is now bound. Suite reproduced: 3803 passed, 5 skipped. No customer data, PII, or private repo/identifier leaks.

Open findings:

  • list_finding_causes docstring promises a single-entry causes — a cause matching nothing returns [], so the documented causes[0] access raises.
  • Same docstring (and _EXPLAIN_FINDING_CAUSES) says counts are "always exact"; the rollup counts the stored status, which diverges from the read status for a lapsed acceptance.
  • --risk-band / --criticality / --tier are closed four-value server vocabularies left as free text, so a typo returns 0 rows with exit 0 — the same reason _MFA_CHOICES exists. Help never names the tier values.
  • New tests pass --criticality tier1 / --tier tier1, values the product never emits; the mock hides that the invocation is a no-match live.
  • --source help says "Alias of --provider", but no --provider option exists on ciem facets / ciem identities (and it means something else on inventory list).
  • Negation convention split in the same group: --no-public (identity filters) vs --not-public (data-store filters).
  • group() help listing dropped finding classes and ciem identity; only the plural identities is now advertised.
  • test_ciem_subgroup_help: "identity" is a substring of "identities", so that assertion can no longer fail.

Review findings on the new bindings, all real:

- `--risk-band` / `--criticality` / `--tier` were free text, but all three
  are CLOSED four-value server vocabularies (`critical`/`high`/`medium`/
  `low`) that fail CLOSED: the identity query appends a FALSE predicate for
  an unrecognized band, and a misspelled tier matches no row. So a typo
  exited 0 with an empty result under a filter the user could see was
  applied — the worst available answer. They now take a click.Choice, the
  same reasoning already applied to `--mfa`.
- Adding a Choice would have removed the ability to select the no-tier
  bucket (the empty value), so `--unclassified` now does that explicitly on
  both commands, folded through the same helper as `--unassigned` for
  owners (renamed `_owner_selector` → `_selector_with_empty`, since it is
  now the shared "…or none of them" fold).
- `list_finding_causes`' docstring said a `cause` lookup returns a
  single-entry list, so a caller would reasonably index `causes[0]`; a
  cause matching nothing returns an EMPTY list. Documented as a list to
  read, not an element to index.
- Dropped "the counts themselves are always exact" from the causes
  docstring and explain text. The counts are whole-population rather than a
  capped lower bound, which is what that line was reaching for, but they
  are computed on the STORED status and so lag a self-expired acceptance —
  calling that "exact" is the kind of claim that gets believed.
- `--source`'s help called itself an alias of `--provider` on two commands
  that have no `--provider` flag (and where `--provider` means something
  else in a sibling command); it now names the dimension instead.
- Unified the tri-state negations on one convention: `--no-sensitive` /
  `--no-public` (was `--not-*`), matching this file's existing `--no-kev` /
  `--no-reachable` rather than splitting spellings inside one group.
- The group help had dropped `finding classes` and `ciem identity` when the
  new siblings were added; both are listed again.
- `test_ciem_subgroup_help` asserted `"identity" in output`, which
  "identities" satisfies, so it could no longer fail. It now compares the
  registered command names as a set.
- The new tests filtered on `tier1`, a value the product never emits —
  invisible behind a mock. Replaced with a real tier, and the fail-closed
  vocabularies now have tests that a typo is rejected at parse time.

Tests: 3809 passed, 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7CkEf1fEa9Y8dBn5Utrx1
lcbill
lcbill previously approved these changes Jul 30, 2026
Comment thread limacharlie/commands/cloudsec.py Outdated
Comment thread tests/unit/test_cli_cloudsec.py Outdated
Comment thread limacharlie/sdk/cloudsec.py
Comment thread limacharlie/commands/cloudsec.py Outdated
@maximelb

Copy link
Copy Markdown
Contributor Author

Re-reviewed against d205e40. All eight earlier findings independently verified as fixed — the Choice sets are correct and complete for all three flags (identityRiskBandExpr's four-token map for risk_band, classifier.KnownTiers() for criticality/tier, which are the same four values), the --unclassified escape hatch really does select the no-tier bucket end to end (IFNULL(i.criticality,'')/IFNULL(criticality,'') IN UNNEST + the gateway's addCloudSecMultiValue forwarding a lone "" as a one-element array), click.Choice(case_sensitive=False) normalizes to the lowercase token the backend matches, _selector_with_empty is semantically identical to _owner_selector with all 8 call sites updated, and no --not-* spelling survives anywhere. Suite: 3809 passed, 5 skipped. No customer data, PII, private repo names or internal identifiers in the diff.

Open findings:

  • --cause --help and CHANGELOG.md:32 still say "the exact count" — the phrase the fix removed from the SDK docstring and _EXPLAIN_FINDING_CAUSES; the single-cause branch has the same stored-status caveat.
  • The three new closed-vocabulary guard tests are vacuous: unmocked + exit_code != 0 passes at exit 1 (no credentials) as well as exit 2 (parse rejection), so deleting the Choice would keep them green.
  • owner_pin docstring guarantee is unconditional; facetOwner clamps Owners+OwnerFacetPins to 50 combined with the unassigned bucket taking a slot, and owner_truncated can't report a dropped pin.
  • --unclassified on ciem reads as the complement of --no-crown-jewel but is a strict superset: criticality="" also covers a declared crown jewel whose matching rule set no tier.

Round-2 review findings:

- The "exact count" phrasing I removed from the SDK docstring and the
  explain text survived in --cause's --help and in the changelog. The
  single-cause branch is the same COUNT(*) over the same filter, so it
  carries the identical stored-status caveat; --help promised otherwise
  while --ai-help was already corrected. Both now say "the count", and the
  help adds the empty-result case.

- The three closed-vocabulary guard tests could not fail. They run
  unmocked and asserted only exit_code != 0, but a VALID value also exits
  non-zero once it reaches the credential-less client — so deleting the
  Choices would have left them green. They now pin the parse failure
  (exit 2 + click's "Invalid value for '--flag'"), and I verified by
  mutation: removing the three Choices fails four tests, restoring them
  passes all four. The pre-existing --mfa test had the same weakness and
  now has a falsifiable sibling.

- owner_pin's docstring stated its guarantee unconditionally. The pins and
  the active owner filter share the facet's 50 slots (and the unassigned
  bucket takes one), so past ~50 combined values a pin can still be
  dropped, and owner_truncated cannot distinguish that from tail
  truncation. Documented as bounded by the cap, with what to render.

- --unclassified and --no-crown-jewel read like complements and are not:
  a rule match sets the crown-jewel flag while the tier stays empty unless
  that rule assigns one, so "no tier" is a strict superset of "not a crown
  jewel". The help for all three flags now separates the two columns.

Tests: 3810 passed, 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7CkEf1fEa9Y8dBn5Utrx1
lcbill
lcbill previously approved these changes Jul 30, 2026
Comment thread limacharlie/sdk/cloudsec.py
Comment thread limacharlie/commands/cloudsec.py
Comment thread tests/unit/test_cli_cloudsec.py Outdated
Comment thread limacharlie/commands/cloudsec.py
@maximelb

Copy link
Copy Markdown
Contributor Author

Round 3, re-reviewed against f286f84. All four round-2 findings independently verified as fixed: --cause's --help and CHANGELOG.md:32 now say "the count" (no "exact" left anywhere on that path); the guards are falsifiable — I deleted the three Choices plus type=_MFA_CHOICES and exactly the four TestClosedVocabularyGuards guards failed, restoring them turned them green; the owner_pin docstring now states the shared 50-slot bound and what to render past it; and the --unclassified / --crown-jewel / --criticality help now separates the tier column from the sensitivity flag, which matches the classifier (a rule match sets Sensitive, Tier stays "" unless that rule assigns one). Also re-checked the rest of the diff: every documented CLI example parses (121 extracted invocations, no unknown option/invalid value), every cs.*(...) example in doc/sdk/other-classes.md matches a real signature and kwargs, no --not-* spelling survives, and no customer data, PII, private repo names or internal identifiers appear. Suite: 3810 passed, 5 skipped.

Open findings:

  • list_finding_causes / _EXPLAIN_FINDING_CAUSES never say the rollup covers ONLY cause-bearing findings (cause_key IS NOT NULL, two stamped kinds) — vulnerability/misconfig/per-grant-CIEM findings are absent, so --class vulnerability returns an empty rollup on an estate full of them and counts never sum to the list.
  • The owner_pin bound reached only the SDK docstring: --help, _EXPLAIN_FINDING_FACETS, CHANGELOG.md and doc/cli/cloud-security.md still promise it unconditionally, and 50+ --owner values drop every pin from the CLI too.
  • test_identities_rejects_unknown_mfa_state is still the vacuous exit_code != 0 version (passes with the Choice deleted); it now duplicates the falsifiable sibling added beside it.
  • --unclassified selects the only value with no facet bucket (both criticality facet passes drop the empty tier), so the rail cannot report a count for a filter it can apply — the one exception to the "a value's count is exactly how many rows selecting it would list" claim.

Round-3 review findings:

- The causes rollup covers only CAUSE-BEARING findings, which the docs
  never said. Causes are stamped on the attack-path classes; vulnerability
  findings — about 90% of a typical estate — deliberately carry none,
  because a per-package cause would evict every attack-path fix from the
  ranking and would cost the sparse index the rollup is fast because of.
  Two consequences a caller will otherwise misread: the counts never sum to
  the worklist total, and `--class vulnerability` returns no causes at all,
  which means "no shared fix on this class" and not "no findings". Now
  stated in both the SDK docstring and the explain text.

- The owner_pin bound reached only the SDK docstring last round. The CLI
  --help, the explain text, doc/cli/cloud-security.md and the changelog all
  still promised it unconditionally — same one-surface-fixed pattern the
  round-2 review flagged, so all four now carry it.

- `--unclassified` selects the one value with NO facet bucket (both the
  identity criticality facet and the DSPM tier facet skip the empty
  string), so it is the single exception to "a facet count is exactly how
  many rows selecting it would list". Said so on both flags and in the SDK
  docstring rather than leaving the general claim to cover a case it does
  not.

- Deleted the older `--mfa` guard: it asserted only a non-zero exit, which
  an accepted value also produces without credentials, so it survived
  deleting the Choice — and it duplicated the falsifiable sibling added
  beside it. The survivor's comment now records why the weak form was not
  testing anything.

Tests: 3809 passed, 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7CkEf1fEa9Y8dBn5Utrx1
lcbill
lcbill previously approved these changes Jul 30, 2026
Comment thread limacharlie/sdk/cloudsec.py Outdated
@maximelb

Copy link
Copy Markdown
Contributor Author

Round 4. Verified the four round-3 fixes against the backend, independently:

  • Causes scope — correct. CauseFacets' three queries all read cause_key IS NOT NULL over the NULL_FILTERED cause index; only firewall_rule / entitled_identity are minted (ClassToxicCombination, ClassPrivilegeEscalation), and the "cause kinds deliberately NOT minted" note does put vulnerability findings at ~90% of an estate. Landed on both the SDK docstring and the explain text.
  • owner_pin bound — correct and now on all four surfaces (--help, explain, doc, changelog). facetOwner concatenates Owners ++ OwnerFacetPins, clamps to 50 owners-first, and the unassigned bucket outranks every pin, so it costs a slot; a pin can be dropped from exactly 50 combined values.
  • Vacuous --mfa guard — deleted; the surviving TestClosedVocabularyGuards sibling pins exit 2 + click's message.
  • --unclassified facet caveat — correct where it landed (both CLI flags, get_identity_facets); see the one open item.

Also spot-checked, all accurate: empty owner= / criticality= / tier= survive the gateway (len(v) > 0 presence gate, addCloudSecMultiValue's one-element-array form), cause key clamp 512, rollup default 20 / cap 200, identity page default 500, store page cap 1000. 3809 passed, 5 skipped reproduced (all 5 skips pre-existing/platform). No customer names, PII, private repo names or internal identifiers in the diff.

Open:

  • get_data_security_facets' tier arg still documents the empty-tier selection without the facet blind spot its identity twin now carries — plus _EXPLAIN_CIEM_FACETS, _EXPLAIN_DATA_SECURITY_FACETS and doc/cli/cloud-security.md:72/87/100, which all still state the "a count predicts the list" claim unqualified. Third round of the same one-surface-fixed pattern. (inline)

Round-4 finding, and the third round where a correction landed on one
surface out of N — so this pass swept for the claim instead of patching
where it was spotted.

"A facet count always describes the population the list returns" is true
for every value except one: both rails skip the EMPTY tier when counting
(the identity criticality facet drops it, and the DSPM tier pass filters
`criticality != ''`), so the bucket --unclassified selects is the single
case where a count cannot predict the list. Every place that made the
unqualified claim now carries the exception — the SDK docstrings for
get_identity_facets, get_data_security_facets and its tier argument, the
shared _identity_query_pairs comment, the two explain texts, the
_data_store_filter_options docstring, doc/cli/cloud-security.md, and the
changelog — and no unqualified "always describe" survives in the tree.

Tests: 3809 passed, 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7CkEf1fEa9Y8dBn5Utrx1
@maximelb
maximelb force-pushed the solve/cloudsec-w7-api-surface-w7api branch from 05939c8 to 043ffae Compare July 30, 2026 04:38
@maximelb

Copy link
Copy Markdown
Contributor Author

Round 5 (final): no open findings. Round-4 fix verified against the backend — both facet passes really do skip the empty tier (identityGroupFacet's v != "" drain, ReadDataStoreFacets pass 3's criticality != '') while both filters are IFNULL(...,'') IN UNNEST(...), and the exception now lands on all 12 surfaces with no unqualified variant left. Also checked the claim is true, not just consistent: the criticality histogram's extra AND IFNULL(i.is_sensitive,false) predicate does not add a second exception, because classifier.classify() returns Classification{} when !Sensitive, so a non-empty tier always implies sensitive. Routes/params/limits re-confirmed against lc_api-go and legion_graph (500 default page, 1000 store cap, 50-owner facet cap, 512-char cause clamp, 20/200 rollup, free-tier field set). 3809 passed / 5 skipped (all pre-existing platform+docstring skips). No PII or internal identifiers.

@maximelb

Copy link
Copy Markdown
Contributor Author

✅ AI-APPROVED

@maximelb
maximelb marked this pull request as ready for review July 30, 2026 07:27
@maximelb
maximelb merged commit f31959a into master Jul 30, 2026
6 checks passed
@maximelb
maximelb deleted the solve/cloudsec-w7-api-surface-w7api branch July 30, 2026 07:27
tomaz-lc added a commit that referenced this pull request Jul 30, 2026
The CHANGELOG's Unreleased section had been accumulating since 5.2.0 while
five releases were tagged out of it, so work that shipped weeks ago was still
listed as unreleased. Each block now sits under the version whose tag actually
carries it, dated from that tag's commit:

- 5.5.2 (July 17): cloudsec fleet overview, provider manifests, CSV exports
  and the inventory provider filter, plus Client.request(raw_response=True)
  and Client.mint_jwt() (#312)
- 5.5.3 (July 20): requests 2.33.0 and the Python 3.10 minimum (#318)
- 5.5.4 (July 23): org set-description (#320)
- 5.5.5 (July 28): the --brief document-hive listings (#321), which had been
  filed under 5.2.0 by mistake, and the resolver_ready fix in the chunked
  resolve merge (#323)
- 5.6.0 (this release): the search open-query and limits commands (#327), and
  the last four cloudsec routes with the findings owner filter (#329)

No entry text changes, only regrouping: every non-heading line is byte
identical to master.

Nothing else in the tree carries a version. setuptools-scm derives the package
version from the git tag, so pushing the 5.6.0 tag once this lands is what
builds and publishes 5.6.0 to PyPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Jul 30, 2026
…gs (#330)

The CHANGELOG's Unreleased section had been accumulating since 5.2.0 while
five releases were tagged out of it, so work that shipped weeks ago was still
listed as unreleased. Each block now sits under the version whose tag actually
carries it, dated from that tag's commit:

- 5.5.2 (July 17): cloudsec fleet overview, provider manifests, CSV exports
  and the inventory provider filter, plus Client.request(raw_response=True)
  and Client.mint_jwt() (#312)
- 5.5.3 (July 20): requests 2.33.0 and the Python 3.10 minimum (#318)
- 5.5.4 (July 23): org set-description (#320)
- 5.5.5 (July 28): the --brief document-hive listings (#321), which had been
  filed under 5.2.0 by mistake, and the resolver_ready fix in the chunked
  resolve merge (#323)
- 5.6.0 (this release): the search open-query and limits commands (#327), and
  the last four cloudsec routes with the findings owner filter (#329)

No entry text changes, only regrouping: every non-heading line is byte
identical to master.

Nothing else in the tree carries a version. setuptools-scm derives the package
version from the git tag, so pushing the 5.6.0 tag once this lands is what
builds and publishes 5.6.0 to PyPI.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants