Skip to content

feat(secret): add --shared-with-me list mode for recipient discovery - #153

Open
c1-squire-dev[bot] wants to merge 7 commits into
mainfrom
highb/vending/shared-secret-list
Open

feat(secret): add --shared-with-me list mode for recipient discovery#153
c1-squire-dev[bot] wants to merge 7 commits into
mainfrom
highb/vending/shared-secret-list

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds cone secret list --shared-with-me, the recipient-discovery path Phase 1 needs: a recipient discovers secrets shared with them and can then view one by its returned vault ID using the existing secret view.

  • Calls the caller-bound POST /api/v1/search/secrets/shared_with_me operation (PaperSecret.SearchSecretsSharedWithMe) through the generated SDK — continuing the architecture from Replace PaperSecret bridge with generated SDK #148; no handwritten bridge.
  • Default secret list behavior, flags, help, and output are unchanged (creator list via SearchMySecrets).
  • The new mode preserves pagination (follows next_page_token to exhaustion) and --query/--status/--type filters across pages.
  • Enforces the endpoint contract: --page-size max 100, --query max 256 chars, no user_id/sort_by/sharing_mode ever sent; an explicit --sharing-mode is rejected with a clear error instead of silently dropped.
  • include_own defaults to false (the endpoint default), opt-in via --include-own (rejected without --shared-with-me).
  • Generalizes the SDK 4XX/5XX error mapping (mapPaperSecretCreateErrormapPaperSecretError) so HTTP failures surface as cone HTTPError uniformly.
  • Output reuses the existing metadata columns (vault ID, display name, type, status, view counts) — no content fetch.

Dependency note

conductorone-sdk-go is pinned to the published v1.29.1 release, which carries the regenerated PaperSecret.SearchSecretsSharedWithMe operation (nightly Speakeasy regen from the refreshed canonical OpenAPI input, including the shared_with_me route). The earlier interim pin (v1.29.1-0.20260905002051-ef0d92d9c5f2) pointed at the unmerged speakeasy-sdk-regen-1784593459 branch head; when the nightly rebuilt that branch from newer main, the pinned commit became unreachable — so the re-pin to the tag landed in this PR rather than as a follow-up.

Tests

  • default list selects the creator endpoint; --shared-with-me selects the shared endpoint and never sends userId/sortBy/sharingMode (asserted on the wire via httptest)
  • two-page response fully consumed with filters preserved and pageToken threaded
  • page-size > 100 and query > 256 rejected; explicit --sharing-mode rejected; --include-own default/opt-in
  • HTTP error mapping (403 body) and context cancellation propagate
  • go test ./..., go vet ./..., golangci-lint (0 issues) all pass locally

Review fixups (2026-09-09)

  • go.mod re-pinned to the published v1.29.1 tag and vendor re-synced, resolving the unmerged-branch pseudo-version fragility.

  • The --include-own / --sharing-mode guards now read the viper-resolved values (CONE_INCLUDE_OWN / CONE_SHARING_MODE, profile config) instead of cmd.Flags().Changed(); tests drive the guards via v.Set to cover exactly that path.

  • SearchSecretsSharedWithMe — and the identical SearchMySecrets / SearchSecretAuditEvents loops — no longer mutate the caller's request and abort with an error when the server repeats a non-empty next_page_token; regression tests cover the repeated-token stop and the no-mutation guarantee.

  • The pagination guard tracks every token the server has handed out, so a cycling next_page_token (A → B → A → …) stops the listing, not just a constant one.

  • The --sharing-mode guard normalizes the viper value exactly like secretListSharingMode (strings.ToLower(strings.TrimSpace(...))), so --sharing-mode ALL, " all", or a profile-config sharing-mode: All still means the no-filter default on --shared-with-me.

  • paginate also caps any listing at 1000 pages, so a server minting a fresh unique token every page terminates with an error too — the no-unbounded-listing guarantee now holds for any server behaviour, and the cap bounds the seen-token set as well.

  • The --sharing-mode guard validates through secretListSharingMode first: invalid values surface the creator path's must be internal, external, or all error, and only a valid concrete mode reports the incompatibility.

highb and others added 2 commits September 5, 2026 00:29
Add cone secret list --shared-with-me, calling the caller-bound
POST /api/v1/search/secrets/shared_with_me operation through the
generated SDK (PaperSecret.SearchSecretsSharedWithMe). The default
creator list (SearchMySecrets), its flags, help, and output stay
unchanged; the new mode preserves pagination, query/status/type
filters, enforces the endpoint's page_size<=100 and query<=256
limits, defaults include_own=false (opt-in via --include-own), and
rejects an explicit --sharing-mode filter instead of silently
dropping it, since the endpoint accepts no user_id, sort_by, or
sharing_mode. Generalize the SDK-error-to-HTTPError mapping
(mapPaperSecretCreateError -> mapPaperSecretError) so the shared
search reports HTTP failures with the same shape as create.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
…redWithMe build

Pin github.com/conductorone/conductorone-sdk-go to
v1.29.1-0.20260905002051-ef0d92d9c5f2 (the speakeasy-sdk-regen
branch head carrying the generated PaperSecret.SearchSecretsSharedWithMe
operation and its request/response models) and re-vendor. This is the
established SDK generation output from the refreshed canonical OpenAPI
input (insulator now serves the C1 main canonical spec including the
shared_with_me route); no generated file is hand-edited. Re-pin to the
v1.29.1 tag once conductorone-sdk-go PR #117 merges and publishes.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread go.mod Outdated
filippo.io/age v1.3.1
github.com/conductorone/baton-sdk v0.3.17
github.com/conductorone/conductorone-sdk-go v1.29.0
github.com/conductorone/conductorone-sdk-go v1.29.1-0.20260905002051-ef0d92d9c5f2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This pins conductorone-sdk-go to a pseudo-version on the unmerged speakeasy-sdk-regen-1784593459 branch rather than a published tag. Because that branch head is not reachable from any released ref, a force-push or branch deletion breaks go mod download/go mod verify and any non-vendored build (GOFLAGS=-mod=mod), and a release cut from this commit would ship an unreviewed pre-release SDK. Worth gating merge on the v1.29.1 tag landing so the re-pin is done here rather than as a follow-up. (confidence: high)

Comment thread cmd/cone/secret.go Outdated
// or sharing-mode filter, so those incompatibilities are rejected here rather
// than silently dropped.
func buildSearchSecretsSharedWithMeRequest(v *viper.Viper, cmd *cobra.Command) (*shared.PaperSecretServiceSearchSecretsSharedWithMeRequest, error) {
if cmd.Flags().Changed(secretSharingFlag) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The incompatibility guards use cmd.Flags().Changed(...), but every value in this file is read through viper, which also resolves CONE_SHARING_MODE/CONE_INCLUDE_OWN env vars and profiles.<name>.sharing-mode config keys (see getSubViperForProfile in config.go). A user who sets sharing-mode via env or profile config gets it silently dropped in --shared-with-me mode instead of the clear error this is meant to produce, and include-own set the same way is silently ignored at line 646 while still being honored by v.GetBool(includeOwnFlag) at line 751. Consider gating on v.GetString(secretSharingFlag) != allFilter / v.GetBool(includeOwnFlag) so the guard matches how the values are actually read. (confidence: high)

Comment thread cmd/cone/secret.go Outdated
PageSize: &pageSize,
}
if query := strings.TrimSpace(v.GetString(queryFlag)); query != "" {
if len(query) > 256 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: len(query) counts bytes, not characters. If the endpoint's limit is 256 characters, a valid non-ASCII query (e.g. 100 CJK characters = 300 bytes) is rejected client-side before it ever reaches the API. Use utf8.RuneCountInString(query) > 256 if the contract is character-based. (confidence: medium — depends on whether the server counts bytes or runes)

Comment thread pkg/client/secret.go Outdated
Comment on lines +304 to +320
for {
resp, err := c.sdk.PaperSecret.SearchSecretsSharedWithMe(ctx, req)
if err != nil {
return nil, mapPaperSecretError(err)
}
if err := NewHTTPError(resp.RawResponse); err != nil {
return nil, err
}
if resp.PaperSecretServiceSearchResponse != nil {
out = append(out, resp.PaperSecretServiceSearchResponse.List...)
token := StringFromPtr(resp.PaperSecretServiceSearchResponse.NextPageToken)
if token != "" {
req.PageToken = &token
continue
}
}
return out, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Two things about this loop, both inherited from SearchMySecrets but now on a newly exported C1Client method. First, it mutates the caller's request in place (req.PageToken = &token), so a caller who reuses a request value gets a stale page token on the next call — copying req locally before looping would keep the method side-effect free. Second, the loop is unbounded: a server that keeps returning the same non-empty nextPageToken spins forever while out grows without limit. A page cap or a check that the token actually changed would bound it. (confidence: medium)

Comment thread cmd/cone/secret_test.go Outdated
Comment on lines +653 to +664
// secretListRunForTest executes the same flag-routing core secretListRun uses,
// against the harness, without the authenticated cmdContext.
func secretListRunForTest(ctx context.Context, h *sharedListHarness, v *viper.Viper, cmd *cobra.Command) error {
if v.GetBool(sharedWithMeFlag) {
return secretListSharedWithMeRun(ctx, h, v, cmd)
}
req, err := buildSearchMySecretsRequest(v)
if err != nil {
return err
}
_, err = h.SearchMySecrets(ctx, req)
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: secretListRunForTest re-implements secretListRun's routing rather than exercising it, so the tests validate a copy that can drift from production. Concretely, the --include-own requires --shared-with-me guard (secret.go:646-648) has no coverage at all because this copy omits it. Extracting the post-cmdContext body of secretListRun into a helper that takes (ctx, creator, sharer, v, cmd) and calling that from both places would close the gap. (confidence: high)

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

General PR Review: feat(secret): add --shared-with-me list mode for recipient discovery

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: none loaded - .claude/skills/ci-review.md was not found at trusted base dd29af4a7260.
Review mode: incremental since bab1ccd6
View review run

Review Summary

The new commit adds a 1,000-page cap to the generic paginate helper (with a regression test that a server minting fresh unique tokens now aborts) and routes the --shared-with-me sharing-mode guard through secretListSharingMode so invalid values surface the creator path's validation error instead of the incompatibility message. Both prior findings are addressed: the unbounded-listing gap at pkg/client/secret.go:268-271 and the invalid-value error at cmd/cone/secret.go:746 are fixed and covered by tests. The full PR diff — including go.mod/go.sum (conductorone-sdk-go v1.29.0 to v1.29.1, consistent with vendor/modules.txt and the new SearchSecretsSharedWithMe operation) — was scanned for security and correctness; no blocking issues found.

Security Issues

None found. The shared-with-me request model carries no user_id/sort_by field, the caller's request is copied per page rather than mutated, and page size / query length are bounded before the call.

Correctness Issues

None found.

Suggestions

  • pkg/client/secret.go:268-271 - the cap counts pages rather than items, so the effective item ceiling varies with --page-size; cone secret audit --page-size 1 now hard-fails past 1,000 events and discards collected results.
  • pkg/client/secret_test.go:393-395 - the cap test performs exactly paginationPageCap real HTTP round trips, pinning its runtime to the constant; testing paginate with an in-memory fetch would decouple them.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/client/secret.go`:
- Around lines 268-271: `paginate` caps the walk at `paginationPageCap` pages, but the
  resulting item ceiling depends entirely on the caller's page size. `secretAuditRun`
  (cmd/cone/secret.go:1080) accepts `--page-size 1`, so a secret with more than 1,000
  audit events now returns an error and drops every page already collected, instead of
  listing them. Consider capping on accumulated item count (for example, abort when
  len(out) exceeds a fixed maximum) or on pages multiplied by the requested page
  size, so the runaway-server protection is independent of page size. Update the
  doc comment on `paginationPageCap` to describe whichever bound is chosen.

In `pkg/client/secret_test.go`:
- Around lines 393-395: `TestSearchSecretsSharedWithMeStopsOnUnboundedFreshTokens`
  asserts requests != paginationPageCap after driving a real httptest server through
  that many round trips, so the test's runtime is tied to the constant and grows if the
  cap is ever raised. Consider adding a direct unit test of the generic `paginate`
  helper with an in-memory `fetch` closure that returns a fresh token each call, and
  assert the fetch invocation count and the page-cap error there; the httptest-based
  test can then cover a much smaller scenario or be dropped.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…n routing

Address review feedback on PR #153:

- The shared-with-me query limit now counts Unicode code points
  (utf8.RuneCountInString) matching the server's protoc-gen-validate
  max_len:256 semantics, not UTF-8 bytes. A 256-character multibyte
  query (512 bytes) passes; 257 characters fails. Regression tests
  cover the multibyte boundary both sides.

- Routing tests now drive the production runSecretList core (extracted
  from secretListRun so the branch selection, include-own-without-
  shared-with-me rejection, and creator-path contract are the exact
  code the CLI executes) instead of a duplicated dispatch. New tests
  pin the include-own guard and the creator path's page-size-1000 /
  created-desc sort / sharing-mode-allowed contract.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

highb and others added 2 commits September 9, 2026 22:10
…nation

The --include-own and --sharing-mode incompatibility guards read
cmd.Flags().Changed(), but every value in this file is consumed through
viper (CONE_INCLUDE_OWN / CONE_SHARING_MODE env vars and profile config
keys), so those sources bypassed the guards. Both guards now read the
viper-resolved value; runSecretList and buildSearchSecretsSharedWithMeRequest
no longer need the cobra command at all. New tests drive the guards via
v.Set to cover exactly that path.

The paper-secret listings (SearchMySecrets, SearchSecretsSharedWithMe,
SearchSecretAuditEvents) shared one pagination loop shape: it mutated the
caller-supplied request in place and followed next_page_token without any
bound, so a server echoing a constant token spun forever while results
grew. paginate() now copies the request per page, threads the token
locally, and stops with an error when the server repeats a non-empty
token. Regression tests cover the repeated-token stop and the
no-mutation guarantee on both caller-facing methods.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
v1.29.0-...-ef0d92d9c5f2 pointed at the head of the unmerged
speakeasy-sdk-regen-1784593459 branch; the nightly has since rebuilt that
branch from newer main, leaving the pinned commit unreachable from any
ref. The v1.29.1 release now publishes the same regenerated SDK
(SearchSecretsSharedWithMe included), so pin the tag instead and re-run
go mod tidy + go mod vendor.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/client/secret.go
Comment thread cmd/cone/secret.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…uard

The page-token guard compared only against the immediately preceding
token, so a server cycling tokens (A → B → A → B …) still looped forever
while results grew. paginate() now records every token the server has
handed out and stops at the first repeat; a cycling-token regression test
covers exactly that shape.

The --sharing-mode guard compared the raw viper value, but
secretListSharingMode normalizes with strings.ToLower(strings.TrimSpace).
--sharing-mode ALL, " all", or a profile-config sharing-mode: All was
rejected on --shared-with-me even though it means the no-filter default.
The guard now normalizes the same way; a test drives it via v.Set with
" ALL " to pin the two paths together.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@highb
highb enabled auto-merge (squash) September 9, 2026 22:21
Comment thread pkg/client/secret.go
Comment thread cmd/cone/secret.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…error

The pagination guard still terminated only on token repetition: a server
minting a fresh unique token every page kept the listing and the seen-token
set growing forever. paginate() now also refuses to follow more than
paginationPageCap pages (1000), so the no-unbounded-listing guarantee in
its doc comment holds for any server behaviour; the cap bounds the seen
set as well. A fresh-token regression test asserts the cap stops the loop.

The --sharing-mode guard on --shared-with-me reported the incompatibility
message for genuinely invalid values, hiding the validation error the
creator path gives. The guard now routes the value through
secretListSharingMode first -- invalid values surface "must be internal,
external, or all"; any concrete mode then reports the incompatibility.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/client/secret.go
Comment on lines +268 to +271
for page := 1; ; page++ {
if page > paginationPageCap {
return nil, fmt.Errorf("listing exceeded %d pages; stopping to avoid an unbounded listing", paginationPageCap)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The cap counts pages, not items, so the effective item ceiling scales with --page-size — 100k for --shared-with-me (max 100/page) but only 1,000 events for cone secret audit --page-size 1, which the flag validation still allows. A legitimate secret with >1,000 audit events at that page size now fails outright and discards everything already collected, rather than terminating a runaway server. Capping on accumulated len(out) (or on pages × page size) would keep the runaway protection while matching the "real listings end long before this" intent regardless of page size. (Confidence: medium)

Comment thread pkg/client/secret_test.go
Comment on lines +393 to +395
if err == nil {
t.Fatal("an endless stream of fresh tokens must abort at the page cap instead of looping")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This test drives a real httptest server through exactly paginationPageCap round trips, so its runtime is pinned to that constant — raising the cap later (which is plausible, given the item-ceiling concern on paginate) silently makes this test proportionally slower. Consider testing paginate directly with a pure in-memory fetch closure so the cap behaviour is asserted without 1,000 HTTP exchanges. (Confidence: medium)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@highb
highb disabled auto-merge September 9, 2026 23:24
@c1-squire-dev

c1-squire-dev Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Squire (Sonnet 5):

Ran build/test/lint verification plus a live end-to-end pass against a dev C1 backend. Summary:

Buildgo build ./... clean, under both this environment's default Go toolchain and a Go 1.26.8 toolchain matching what CI resolves from go.mod's go 1.26 directive.

Note for reviewers: cmd/cone/secret.go has req.IncludeOwn = new(true). That reads like invalid Go (new takes a type), but it's valid — this Go toolchain's new builtin now also accepts a value expression and returns a pointer to a copy of it. Confirmed with an isolated repro; not a defect.

Test — CI's exact command (go test -v -covermode=count -json ./...): 50/50 pass, 0 failures, including all 16 --shared-with-me-specific tests (flag routing/rejection + client pagination/wire-contract coverage).

Vetgo vet ./... clean.

Lintgolangci-lint v2.9.0 (matching CI's pin exactly): 0 issues, once run under a Go 1.26 toolchain. (Running it under this sandbox's newer default Go toolchain made the linter binary itself panic — a local toolchain/linter version mismatch, not a PR issue.)

Live end-to-end — started the backend service chain needed (postgres, dynamodb, temporal, valkey, innkeeper, ratelimit, session, vault, tenant seed, frontend, auth/api/accounts, envoy, temporal-worker, db-stream, conductor) against a c1dev-okta dev tenant, and minted two distinct dev users (a sharer and a recipient) so the test is genuinely cross-user:

  1. Sharer creates a secret and shares it to the recipient — succeeded.
  2. Recipient's new cone secret list --shared-with-me correctly returns it.
  3. Recipient's default cone secret list (creator endpoint) correctly returns nothing.
  4. Sharer's default list still shows their own secret; sharer's --shared-with-me is correctly empty.
  5. Recipient decrypts and views the content via cone secret view <vault-id> — full Age round trip works.
  6. Both guard rails reject correctly live: --shared-with-me --sharing-mode ...--sharing-mode is not supported with --shared-with-me; --include-own without --shared-with-me--include-own requires --shared-with-me.

Not exercised live (already covered by the PR's own unit/httptest suite): multi-page pagination, and --include-own actually returning a self-shared secret.

Side note unrelated to this PR: resolving an internal recipient by email (--user) hit a pre-existing gap in this dev environment's user-search — worked around with --allowed-user-ids (an existing, documented cone flag) instead.

No blockers found.

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