cloudsec: bind the last four routes + the findings owner filter - #329
Conversation
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
|
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; Open findings:
|
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
|
Re-reviewed against Open findings:
|
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
|
Round 3, re-reviewed against Open findings:
|
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
|
Round 4. Verified the four round-3 fixes against the backend, independently:
Also spot-checked, all accurate: empty Open:
|
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
05939c8 to
043ffae
Compare
|
Round 5 (final): no open findings. Round-4 fix verified against the backend — both facet passes really do skip the empty tier ( |
|
✅ AI-APPROVED |
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>
…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>
What was asked
Close the remaining gaps in the
cloudsecintegration surface: bind the four/v1/cloudsec/*routes the SDK/CLI did not cover (
/findings/causes,/ciem/identities,/data-security/stores,/free-tier), and expose theownerfindings filter the API acceptsbut 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,
--outputrendering, docstrings,--ai-helpexplain text, tests:list_finding_causes()cloudsec finding causeslist_identity_access()cloudsec ciem identitieslist_data_stores()cloudsec data-security storesget_free_tier()cloudsec free-tierThe owner filter.
finding list|facetsandexport findingstake--owner(repeatable)and
--unassigned;finding facetsalso takes--owner-pin. Without this,finding set-ownercould assign an owner that no API caller could then filter by — the write existedand 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_selectorfolds them and returnsNonewhen neither is given, so an unfiltered readsends exactly what it sent before.
--owner-pinis deliberately separate and documented asnot a filter: it keeps named owners visible in the
ownerfacet, which is capped at the top50 by count (
owner_truncatedreports drops), and selects no rows.Two facet bindings gained their list's selectors.
ciem facetsanddata-security facetspreviously 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
Nonerather than being a plain flag, and--mfais athree-value choice because
unknownis notoff.Files:
limacharlie/sdk/cloudsec.py,limacharlie/commands/cloudsec.py, tests intests/unit/test_sdk_cloudsec.py+tests/unit/test_cli_cloudsec.py(+ the cloudsecsubcommand 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 aremanaged through the generic
hivecommands, as the module header has always stated.Testing
pytest tests/unit/ tests/microbenchmarks/ --benchmark-disable), which is what CI runs.as
?owner=(dropping it would silently widen the read to the whole estate), that no ownerflags 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 pathwith the right selectors.
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,
--unassignedreturned onlyunowned 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