Skip to content

fix(sdk): Batch access queries - #3398

Open
moshloop wants to merge 7 commits into
mainfrom
fix/sdk-batch-access-queries
Open

fix(sdk): Batch access queries#3398
moshloop wants to merge 7 commits into
mainfrom
fix/sdk-batch-access-queries

Conversation

@moshloop

@moshloop moshloop commented Aug 7, 2026

Copy link
Copy Markdown
Member

What

  • Batch oversized access-filter queries within bounded request limits.
  • Preserve merged ordering, limits, and totals.
  • Return malformed search filters as structured JSON bad requests.

Why

  • Keep access queries reliable at scale and make filter errors consistent for clients.

Summary by CodeRabbit

  • New Features
    • Added access commands for users, groups, roles, permissions, logs, and reviews, with filtering, exports, summaries, and shell completion.
    • Added version output with build, commit, date, and platform details.
    • Added configurable credential storage using local files or the operating system keychain.
  • Improvements
    • Improved OIDC token refresh, persistence, reauthentication handling, and error messages.
    • Enhanced filtering with repeated values, case-insensitive matching, exclusions, and clearer validation errors.
    • Added safer credential synchronization and concurrent-access protection.

…h tokens

Treat skipped database probes as optional and surface detailed auth or database failures for actionable diagnostics.
BREAKING CHANGE: OIDC refresh is now opt-in; pass --refresh to spend a single-use refresh token.

Claude-Session-Id: 4ee06f93-f1d6-414c-8cbd-3199fd82c1f3
Separate credentials from config.json and protect rotating refresh-token exchanges with atomic writes and cross-process locking. Persist the selected file or OS keychain backend, migrate legacy inline secrets, cache OIDC endpoints, and mark terminal invalid_grant failures for re-authentication.
Extract boolean flag and server-side filter parsing into clientcmd so catalog and access commands share one implementation.
This removes duplicated parsing logic and keeps defaults and filter labels consistent.
Add remote access auditing for permissions, users, groups, roles, sign-in logs, and reviews with MatchItem filtering, rollups, group expansion, and machine-readable exports. Normalize filter middleware to preserve case-insensitive alternatives, exclusions, repeated values, and malformed-filter errors.
Make faro version output identify the source revision, build time, toolchain, platform, and dirty state across stamped and direct Go builds. Add a machine-local build and install workflow with an overridable destination.
Split oversized access-filter requests into bounded batches while preserving merged ordering, limits, and totals. Return malformed search filters as structured JSON bad requests for consistent client handling.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgolang/​github.com/​gofrs/​flock@​v0.13.0100100100100100
Addedgolang/​github.com/​zalando/​go-keyring@​v0.2.8100100100100100

View full report

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The pull request adds credential-store-backed OIDC authentication, access-control SDK and Faro commands, deterministic database filters, shared CLI helpers, and Faro build metadata with local installation targets.

Changes

Credential storage and OIDC lifecycle

Layer / File(s) Summary
Credential stores and locking
clientcmd/credentials/*, go.mod
Adds file and keychain stores, atomic writes, writability checks, credential cloning, and cross-process locks.
Context credential integration
clientcmd/context.go, clientcmd/context_credentials.go, clientcmd/*_test.go
Moves credentials out of config.json, hydrates and migrates legacy credentials, synchronizes store changes, and tracks reauthentication and discovery metadata.
OIDC refresh flow
auth/oidcclient/oidcclient.go, clientcmd/api_client.go, clientcmd/refresh_test.go
Adds structured token errors and locked refresh-token rotation with terminal-error handling and persistence.
Login and status handling
clientcmd/auth_login.go, clientcmd/whoami*.go, clientcmd/*_test.go
Adds credential-store selection, discovery endpoint caching, reauthentication reporting, and explicit refresh behavior.

Access SDK and Faro commands

Layer / File(s) Summary
Access SDK contracts and identity resolution
sdk/access.go, sdk/access_identity.go, sdk/access_filter.go, sdk/access_logs.go
Adds access models, identity resolution, group membership hydration, grant retrieval, rollups, history queries, and PostgREST helpers.
Batched access and history retrieval
sdk/access_batch.go, sdk/*_test.go
Splits oversized access queries, merges totals, and validates batching, filtering, hydration, and ordering.
Faro access commands
faro/access*.go
Adds users, groups, roles, permissions, logs, and reviews commands with filters, completion, related-data loading, rollups, and command help.
Access rendering and validation
faro/access_rows.go, faro/access_rollup.go, faro/access_print.go, faro/access_test.go
Adds grouped summaries, flat export rows, explicit columns, timestamp formatting, and rendering tests.

Query filters and shared helpers

Layer / File(s) Summary
Database filter transformation
db/middleware.go, db/middleware_test.go
Processes repeated positive, negative, wildcard, and timestamp filters into deterministic PostgREST expressions and returns JSON 400 errors for malformed filters.
Shared CLI helpers
clientcmd/flags.go, cmd/access_*.go, cmd/catalog_*.go
Adds shared boolean and filter lookup helpers and uses them in access and catalog commands.

Faro build metadata

Layer / File(s) Summary
Build metadata and version command
Makefile, faro/version.go, faro/version_test.go, faro/main.go
Embeds version, commit, and UTC build date values and exposes resolved build information through the Faro version command.
Build commands and help wiring
Makefile, faro/catalog_help.go, faro/access_help.go, faro/main.go
Adds native build and configurable installation targets, updates help parsing, and registers access and catalog command documentation.

Sequence Diagram(s)

sequenceDiagram
  participant FaroCommand
  participant AccessSDK
  participant PostgREST
  participant Renderer
  FaroCommand->>AccessSDK: Resolve filters and request access data
  AccessSDK->>PostgREST: Query identities, grants, logs, or reviews
  PostgREST-->>AccessSDK: Return rows and totals
  AccessSDK-->>FaroCommand: Return hydrated results
  FaroCommand->>Renderer: Render grouped or flat output
Loading
sequenceDiagram
  participant Login
  participant OIDCProvider
  participant CredentialStore
  Login->>OIDCProvider: Discover endpoints and exchange authorization code
  OIDCProvider-->>Login: Return tokens and discovery metadata
  Login->>CredentialStore: Save credentials and context metadata
  CredentialStore-->>Login: Confirm persistence
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the primary SDK change: batching oversized access queries.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sdk-batch-access-queries
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/sdk-batch-access-queries

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 15

🧹 Nitpick comments (14)
clientcmd/credentials/keychain.go (1)

27-34: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Keychain items are not scoped to the config directory.

FileStore is rooted at dir, but KeychainStore keys items by context name under the fixed service mission-control. Two config directories (for example, a test run or a CI job that sets XDG_CONFIG_HOME) share one keychain namespace. A context named beta in one config directory then reads and overwrites the secret of a context named beta in another config directory. Consider scoping the keychain account or service by dir so the two stores have the same isolation contract.

♻️ Example scoping by config directory
 func NewKeychainStore(dir string) *KeychainStore {
-	return &KeychainStore{service: keychainService, ephemeral: NewFileStore(dir)}
+	sum := sha256.Sum256([]byte(dir))
+	return &KeychainStore{
+		service:   keychainService + ":" + hex.EncodeToString(sum[:8]),
+		ephemeral: NewFileStore(dir),
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/credentials/keychain.go` around lines 27 - 34, Update
NewKeychainStore and KeychainStore so keychain entries are scoped by the config
directory dir, rather than always using the fixed keychainService. Derive a
stable service or account namespace from dir while preserving the existing
context-name keying and ephemeral FileStore behavior.
clientcmd/credentials/keychain_test.go (1)

13-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add ginkgo.Label("ignore_local") and drop ginkgo.Ordered.

These specs need the OS keychain, which is an external service. The coding guidelines require the ignore_local label for such tests. The environment-variable gate does not replace the label, because label-based filtering is what excludes the specs from local runs.

Each spec also sets up its own state in BeforeEach and cleans up with DeferCleanup, so no spec depends on the order of a previous one. Remove ginkgo.Ordered unless a sequential dependency exists.

♻️ Proposed change
-var _ = ginkgo.Describe("KeychainStore", ginkgo.Ordered, func() {
+var _ = ginkgo.Describe("KeychainStore", ginkgo.Label("ignore_local"), func() {

Based on coding guidelines: "Use ginkgo.Label(\"ignore_local\") for tests requiring external services" and "Use ginkgo.Ordered only when test steps must run sequentially".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/credentials/keychain_test.go` around lines 13 - 26, Update the
KeychainStore Describe declaration to add the ginkgo.Label("ignore_local") label
and remove ginkgo.Ordered. Keep the existing BeforeEach environment gate and
DeferCleanup behavior unchanged.

Source: Coding guidelines

clientcmd/auth_login_test.go (1)

25-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reset loginCredentialStore in AfterEach.

AfterEach resets loginServer and loginToken, but the new package-level flag variable loginCredentialStore keeps its value between specs. A spec that sets it leaks the store selection into later specs in the same package.

♻️ Proposed change
 	ginkgo.AfterEach(func() {
 		oidcLogin = oldOIDCLogin
 		loginServer = ""
 		loginToken = ""
+		loginCredentialStore = ""
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/auth_login_test.go` around lines 25 - 29, Update the
ginkgo.AfterEach cleanup to reset the package-level loginCredentialStore
variable to its original/default value, alongside loginServer and loginToken, so
each spec starts with an isolated credential-store selection.
clientcmd/api_client.go (1)

59-68: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Resolve the config and store inside the lock.

LoadConfig and cfg.store() run before credentials.WithLock. If another process changes credential_store in that window, this process reloads the credential inside the lock but writes it back through the store selected from the stale config. The rotated refresh token then lands in the store that is no longer active.

Move both calls into the locked function so the store choice matches the state observed under the lock.

♻️ Proposed refactor
 func refreshContextToken(mcCtx *MCContext) error {
-	cfg, err := LoadConfig()
-	if err != nil {
-		return err
-	}
-	store, err := cfg.store()
-	if err != nil {
-		return err
-	}
-
 	return credentials.WithLock(configDir(), func() error {
+		cfg, err := LoadConfig()
+		if err != nil {
+			return err
+		}
+		store, err := cfg.store()
+		if err != nil {
+			return err
+		}
+
 		cred, err := store.Get(mcCtx.Name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/api_client.go` around lines 59 - 68, Move the LoadConfig and
cfg.store calls into the credentials.WithLock callback so configuration and
store selection occur while the lock is held. Preserve their existing error
returns and use the lock-scoped store for all subsequent credential operations,
ensuring the selected store reflects the current configuration.
clientcmd/whoami_test.go (1)

51-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a spec for the NeedsReauth branch of probeAuth.

probeAuth gained a new first branch in clientcmd/whoami.go lines 210-215. It sets Status to invalid, sets RefreshStatus to unavailable: <reason>, and returns ReauthError(). No spec covers it. A spec here needs no network server, because the branch returns before callWhoami.

💚 Proposed spec
+	ginkgo.It("reports contexts that need re-authentication as invalid", func() {
+		report := probeAuth(context.TODO(), &MCContext{
+			Name:        "test",
+			Server:      "http://mission-control.local",
+			NeedsReauth: "refresh token rejected",
+		}, "", false)
+
+		Expect(report.Status).To(Equal("invalid"))
+		Expect(report.TokenSource).To(Equal("oidc"))
+		Expect(report.RefreshStatus).To(Equal("unavailable: refresh token rejected"))
+		Expect(report.Error).To(ContainSubstring("needs re-authentication"))
+	})
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/whoami_test.go` around lines 51 - 57, Add a test in
clientcmd/whoami_test.go covering probeAuth’s NeedsReauth branch: provide a
reauthentication-needed result, assert the auth status is invalid, RefreshStatus
is “unavailable: <reason>”, and verify ReauthError is returned without requiring
a network server. Anchor the setup and assertions to probeAuth and the existing
whoami auth test patterns.
faro/access_permissions.go (1)

99-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that --expand-groups can produce more rows than --limit.

--limit is applied server-side to the grant rows. expandGroups then adds one synthetic row per active group member. The printed row count can therefore exceed permissionsLimit. The behaviour looks intentional, but the flag help at line 181 states "Maximum number of rows", which contradicts it.

Update the --limit help text or the --expand-groups help text to state that the limit applies to grants before expansion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@faro/access_permissions.go` around lines 99 - 105, Update the CLI help text
for permissionsLimit or permissionsExpandGroups to clarify that --limit applies
to grant rows before group expansion and that --expand-groups may produce
additional rows. Keep the existing expansion behavior in expandGroups unchanged.
sdk/access_batch.go (2)

39-44: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Document the ordering precondition, and guard the compare == nil case.

The merged top-k is only correct when the client compare matches the server order param. Each batch is limited server-side, so a row that is globally in the top limit must also be in its own batch's top limit. That holds only under a matching order.

If compare is nil, line 42 still truncates. The retained rows are then the first limit rows in batch-concatenation order, which is not a global top-k. All current callers pass a compare, so this is latent.

Add a short doc comment on pgGetAccess that states the precondition, and skip truncation when compare is nil and more than one batch ran.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_batch.go` around lines 39 - 44, Update pgGetAccess to document
that global top-limit correctness requires compare to match the server order
parameter. Guard the limit truncation so it only runs when compare is non-nil,
or when no more than one batch was executed; preserve existing sorting and
truncation behavior otherwise.

25-37: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider running batches concurrently with a bounded worker count.

The batches are independent and are issued sequentially. For a large ID set the wall-clock time scales linearly with the batch count. A bounded errgroup with a small concurrency limit would reduce latency without overloading the proxy.

This is optional. Keep the sequential path if predictable server load matters more than latency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_batch.go` around lines 25 - 37, Optionally update the
batch-processing loop in the surrounding access-batch function to execute
independent client.pgGet calls through an errgroup with a small bounded
concurrency limit, while preserving rows aggregation, totalKnown/total handling,
and immediate error propagation; retain the current sequential loop if
predictable server load is preferred.
sdk/access_batch_test.go (1)

82-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the ordering assumption in the fixture explicit.

Line 82 encodes the expected winner through 100-request and %02d. The assertion at line 95 then depends on three facts: the batch count stays below 100, %02d keeps the values lexicographically comparable, and ListAccessGrants issues no extra requests beyond the grant batches. If a future change makes ListAccessGrants issue a hydration request, requests.Load() no longer equals the batch count, and lines 93 and 95 fail for an unrelated reason.

Consider deriving the expected user from the response bodies the handler recorded, rather than from the request counter.

Also applies to: 93-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_batch_test.go` at line 82, Update the access-grant fixture
assertions around ListAccessGrants to derive the expected user from the response
bodies recorded by the handler, rather than calculating it from requests.Load()
and the 100-request ordering formula. Keep the ordering expectation explicit
while removing dependence on batch-count limits, %02d formatting, or additional
hydration requests.
sdk/access_filter.go (1)

51-59: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider a safety cap on the unbounded candidate fetch.

params.Del("limit") removes the server-side bound. When a name filter is present, the client downloads every non-deleted row of the identity table before it matches locally. On a large tenant this can be a very large response. Add a hard ceiling and warn when it is reached, so the command degrades predictably instead of transferring the whole table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_filter.go` around lines 51 - 59, Update matchingIdentityIDs to
retain a hard maximum on the candidate fetch instead of removing the limit
entirely. Apply the cap through params and emit a warning when the fetched
candidates reach that ceiling, while preserving the existing local matching and
error behavior.
faro/access.go (1)

23-29: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bind the shared context to interrupt signals.

accessClient returns context.Background(). Every access subcommand uses it, and the SDK fan-out issues one request per id batch. A slow or hung server leaves the CLI with no cancellation path. Use signal.NotifyContext so Ctrl-C cancels the in-flight requests, and return the cancel function to the caller.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@faro/access.go` around lines 23 - 29, Update accessClient to create a
signal.NotifyContext for interrupt signals instead of context.Background(), and
extend its return values with the associated cancel function. Ensure the cancel
function is returned on successful client initialization and also invoked before
returning when RemoteClient fails; update all callers to receive and defer the
cancel function while preserving the shared context for SDK requests.
faro/access_users.go (1)

57-63: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Report truncation for the grants section.

ListAccessGrants returns a total, but this call discards it. The list paths call warnTruncated. Without it, access users get can silently show a truncated grant list.

♻️ Proposed change
-		grants, _, err := client.ListAccessGrants(ctx, sdk.AccessGrantOptions{UserIDs: []string{user.ID.String()}})
+		grants, total, err := client.ListAccessGrants(ctx, sdk.AccessGrantOptions{UserIDs: []string{user.ID.String()}})
 		if err != nil {
 			return nil, err
 		}
+		warnTruncated("access grants", len(grants), total)
 		result.Access = grants
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@faro/access_users.go` around lines 57 - 63, Update the access-grants handling
in the user retrieval flow to retain the total returned by ListAccessGrants and
pass it with the fetched grants to warnTruncated. Preserve the existing error
handling and result.Access assignment while ensuring access users get reports
when the grant list is truncated.
faro/access_logs.go (1)

34-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Duplicated history-command body in faro/access_logs.go and faro/access_reviews.go. Both RunE bodies perform the same sequence: build the client, resolve config IDs, resolve user IDs, parse --since, short-circuit on an empty user match, call one SDK list method, warn on truncation, then print. Only the SDK call and the result type differ. A shared generic helper that accepts a fetch function and a row mapper would remove the duplication and keep the two commands in sync.

  • faro/access_logs.go#L34-L70: extract the shared sequence into a helper and call it with client.ListAccessLogs and accessLogRows.
  • faro/access_reviews.go#L33-L69: call the same helper with client.ListAccessReviews and accessReviewRows.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@faro/access_logs.go` around lines 34 - 70, Extract the duplicated
history-command sequence from faro/access_logs.go:34-70 into a shared generic
helper that handles client creation, ID resolution, since parsing, empty-user
short-circuiting, truncation warnings, and result printing; parameterize it with
the SDK fetch function and row mapper. Update faro/access_logs.go:34-70 to call
the helper with client.ListAccessLogs and accessLogRows, and update
faro/access_reviews.go:33-69 to call it with client.ListAccessReviews and
accessReviewRows.
faro/access_test.go (1)

35-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add ginkgo.GinkgoRecover inside the accessServer handler.

The handler is invoked by httptest in a non-spec goroutine. Call ginkgo.Fail from the spec goroutine, or add defer ginkgo.GinkgoRecover() at the top of this handler so Ginkgo reports the failure correctly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@faro/access_test.go` around lines 35 - 48, Add defer ginkgo.GinkgoRecover()
at the start of the HTTP handler in accessServer, before any request processing
or potential ginkgo.Fail call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@auth/oidcclient/oidcclient.go`:
- Around line 142-146: Increase the response-body read limit in the non-OK
branch before constructing and unmarshalling TokenError, using a size sufficient
for normal OAuth error documents so the error code is preserved and Terminal()
can classify invalid_grant correctly. Keep the existing TokenError construction
and return flow unchanged.

In `@clientcmd/api_client.go`:
- Around line 59-68: The refreshContextToken flow in clientcmd/api_client.go
must load the configuration and select cfg.store() inside the callback passed to
credentials.WithLock, so all subsequent decisions and writes use state observed
under the lock. In clientcmd/whoami_token.go, retain saveConfigLocked but ensure
its caller passes the configuration loaded within that locked callback,
preventing endpoint-cache persistence from overwriting concurrent context
changes.
- Around line 68-84: Ensure the refresh flow around refreshContextToken uses a
non-nil MCContext before calling mcCtx.credential(), applying credentials, or
accessing credential fields inside credentials.WithLock. Add the guard at the
entry point while preserving the existing locked read-modify-write behavior and
reauthentication checks.

In `@clientcmd/refresh_test.go`:
- Around line 26-41: Add defer ginkgo.GinkgoRecover() at the start of the
httptest server HTTP handler in newTokenEndpoint, and in the handler-based
assertion paths such as writeRotatedTokens and the os.Chmod assertion, so Gomega
failures from server goroutines are recovered and reported to the spec.

In `@clientcmd/whoami_token.go`:
- Around line 53-70: Update contextTokenEndpoint to validate
endpoints.TokenEndpoint immediately after discoverOIDCEndpoints returns,
returning an error when it is empty before assigning or caching the discovery
result. Preserve the existing endpoint caching and successful return behavior
for valid token endpoints.

In `@faro/access_filter_test.go`:
- Around line 39-56: Update the test setup around the DeferCleanup callback to
capture the prior values of all eleven mutated package-level variables,
including the permissions, access-logs, and access-reviews limits and flags.
Restore each captured value in cleanup rather than hard-coding defaults,
ensuring later AccessPermissions, AccessLogs, and AccessReviews specs see the
original state.

In `@faro/access_groups.go`:
- Around line 64-70: Update the access-grant retrieval in the access-group flow
to retain the total returned by ListAccessGrants instead of discarding it. After
the successful call, invoke warnTruncated with "access grants", len(grants), and
the returned total before assigning result.Access, while preserving the existing
error handling.
- Around line 94-104: Update completeAccessGroupIDs to return each group's Name
instead of ID.String(), so Cobra can prefix-filter completions by the partially
typed group name. Also update the related ValidArgs filtering to match group
names rather than IDs, preserving the existing completion behavior and limits.

In `@faro/access_permissions.go`:
- Around line 74-84: Update the empty-result branch in the permissionsUser flow
to set Derived: true on both AccessSummaryByUserResult and
AccessSummaryByConfigResult, matching the non-empty rollup paths; leave the
AccessPermissionsResult path unchanged.

In `@Makefile`:
- Around line 5-6: Change the GIT_COMMIT and BUILD_DATE definitions in the
Makefile to simply expanded variables so git and date are evaluated once when
Make reads the file, ensuring all Faro platform builds in one invocation use
identical metadata.

In `@sdk/access_batch_test.go`:
- Around line 77-83: Add defer ginkgo.GinkgoRecover() at the start of the
httptest HTTP handler passed to http.HandlerFunc, before the request counter and
Gomega assertion, so assertion failures from the handler goroutine are recovered
by Ginkgo.

In `@sdk/access_identity.go`:
- Around line 399-415: Update the generic pgGetIn batching flow to restore
global ordering after concatenating batches: accept an optional comparison
function and sort the merged out slice after the loop when provided. Pass the
name comparison from ListExternalUsers, ListExternalGroups, and
ListExternalRoles, while preserving existing behavior for callers without
ordering requirements.
- Around line 375-381: Update nameOrEmailFilter and nameOrAliasFilter to escape
each interpolated value for PostgREST filter syntax: escape embedded backslashes
and double quotes, then wrap the value in PostgREST double quotes before
inserting it into the ilike and aliases expressions. Reuse the escaped value
consistently across every occurrence.

In `@sdk/access_test.go`:
- Around line 30-44: Add defer ginkgo.GinkgoRecover() as the first statement in
the pgRoutes HTTP handler and the inline HTTP handlers at sdk/access_test.go
lines 89-94, 105-111, and 415-421. Also add it as the first statement in the
identityRoutes handler at sdk/access_filter_test.go lines 26-41, so assertions
from server goroutines report through Ginkgo correctly.

In `@sdk/access.go`:
- Around line 67-69: Update ListAccessGrants to pass the o.User wildcard value
through a quotePostgRESTValue helper before constructing the PostgREST or
filter. Add the helper using strings.NewReplacer to escape backslashes and
double quotes, then wrap the value in double quotes so reserved characters
remain literal.

---

Nitpick comments:
In `@clientcmd/api_client.go`:
- Around line 59-68: Move the LoadConfig and cfg.store calls into the
credentials.WithLock callback so configuration and store selection occur while
the lock is held. Preserve their existing error returns and use the lock-scoped
store for all subsequent credential operations, ensuring the selected store
reflects the current configuration.

In `@clientcmd/auth_login_test.go`:
- Around line 25-29: Update the ginkgo.AfterEach cleanup to reset the
package-level loginCredentialStore variable to its original/default value,
alongside loginServer and loginToken, so each spec starts with an isolated
credential-store selection.

In `@clientcmd/credentials/keychain_test.go`:
- Around line 13-26: Update the KeychainStore Describe declaration to add the
ginkgo.Label("ignore_local") label and remove ginkgo.Ordered. Keep the existing
BeforeEach environment gate and DeferCleanup behavior unchanged.

In `@clientcmd/credentials/keychain.go`:
- Around line 27-34: Update NewKeychainStore and KeychainStore so keychain
entries are scoped by the config directory dir, rather than always using the
fixed keychainService. Derive a stable service or account namespace from dir
while preserving the existing context-name keying and ephemeral FileStore
behavior.

In `@clientcmd/whoami_test.go`:
- Around line 51-57: Add a test in clientcmd/whoami_test.go covering probeAuth’s
NeedsReauth branch: provide a reauthentication-needed result, assert the auth
status is invalid, RefreshStatus is “unavailable: <reason>”, and verify
ReauthError is returned without requiring a network server. Anchor the setup and
assertions to probeAuth and the existing whoami auth test patterns.

In `@faro/access_logs.go`:
- Around line 34-70: Extract the duplicated history-command sequence from
faro/access_logs.go:34-70 into a shared generic helper that handles client
creation, ID resolution, since parsing, empty-user short-circuiting, truncation
warnings, and result printing; parameterize it with the SDK fetch function and
row mapper. Update faro/access_logs.go:34-70 to call the helper with
client.ListAccessLogs and accessLogRows, and update faro/access_reviews.go:33-69
to call it with client.ListAccessReviews and accessReviewRows.

In `@faro/access_permissions.go`:
- Around line 99-105: Update the CLI help text for permissionsLimit or
permissionsExpandGroups to clarify that --limit applies to grant rows before
group expansion and that --expand-groups may produce additional rows. Keep the
existing expansion behavior in expandGroups unchanged.

In `@faro/access_test.go`:
- Around line 35-48: Add defer ginkgo.GinkgoRecover() at the start of the HTTP
handler in accessServer, before any request processing or potential ginkgo.Fail
call.

In `@faro/access_users.go`:
- Around line 57-63: Update the access-grants handling in the user retrieval
flow to retain the total returned by ListAccessGrants and pass it with the
fetched grants to warnTruncated. Preserve the existing error handling and
result.Access assignment while ensuring access users get reports when the grant
list is truncated.

In `@faro/access.go`:
- Around line 23-29: Update accessClient to create a signal.NotifyContext for
interrupt signals instead of context.Background(), and extend its return values
with the associated cancel function. Ensure the cancel function is returned on
successful client initialization and also invoked before returning when
RemoteClient fails; update all callers to receive and defer the cancel function
while preserving the shared context for SDK requests.

In `@sdk/access_batch_test.go`:
- Line 82: Update the access-grant fixture assertions around ListAccessGrants to
derive the expected user from the response bodies recorded by the handler,
rather than calculating it from requests.Load() and the 100-request ordering
formula. Keep the ordering expectation explicit while removing dependence on
batch-count limits, %02d formatting, or additional hydration requests.

In `@sdk/access_batch.go`:
- Around line 39-44: Update pgGetAccess to document that global top-limit
correctness requires compare to match the server order parameter. Guard the
limit truncation so it only runs when compare is non-nil, or when no more than
one batch was executed; preserve existing sorting and truncation behavior
otherwise.
- Around line 25-37: Optionally update the batch-processing loop in the
surrounding access-batch function to execute independent client.pgGet calls
through an errgroup with a small bounded concurrency limit, while preserving
rows aggregation, totalKnown/total handling, and immediate error propagation;
retain the current sequential loop if predictable server load is preferred.

In `@sdk/access_filter.go`:
- Around line 51-59: Update matchingIdentityIDs to retain a hard maximum on the
candidate fetch instead of removing the limit entirely. Apply the cap through
params and emit a warning when the fetched candidates reach that ceiling, while
preserving the existing local matching and error behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2cc08a62-8130-4b90-8810-6ece33e63c4e

📥 Commits

Reviewing files that changed from the base of the PR and between 37511f9 and f1a77ce.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (57)
  • Makefile
  • auth/oidcclient/oidcclient.go
  • clientcmd/api_client.go
  • clientcmd/auth_login.go
  • clientcmd/auth_login_test.go
  • clientcmd/context.go
  • clientcmd/context_credentials.go
  • clientcmd/context_credentials_test.go
  • clientcmd/context_test.go
  • clientcmd/credentials/file.go
  • clientcmd/credentials/file_test.go
  • clientcmd/credentials/keychain.go
  • clientcmd/credentials/keychain_test.go
  • clientcmd/credentials/lock.go
  • clientcmd/credentials/store.go
  • clientcmd/credentials/suite_test.go
  • clientcmd/flags.go
  • clientcmd/refresh_test.go
  • clientcmd/whoami.go
  • clientcmd/whoami_test.go
  • clientcmd/whoami_token.go
  • cmd/access_groups.go
  • cmd/access_roles.go
  • cmd/access_users.go
  • cmd/catalog_entity.go
  • cmd/catalog_get.go
  • db/middleware.go
  • db/middleware_test.go
  • faro/access.go
  • faro/access_filter_test.go
  • faro/access_groups.go
  • faro/access_help.go
  • faro/access_help_test.go
  • faro/access_logs.go
  • faro/access_permissions.go
  • faro/access_print.go
  • faro/access_reviews.go
  • faro/access_roles.go
  • faro/access_rollup.go
  • faro/access_rows.go
  • faro/access_test.go
  • faro/access_users.go
  • faro/catalog_help.go
  • faro/main.go
  • faro/version.go
  • faro/version_test.go
  • go.mod
  • sdk/access.go
  • sdk/access_batch.go
  • sdk/access_batch_test.go
  • sdk/access_filter.go
  • sdk/access_filter_test.go
  • sdk/access_history_test.go
  • sdk/access_identity.go
  • sdk/access_logs.go
  • sdk/access_test.go
  • sdk/client.go

Comment on lines 142 to +146
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
msg := strings.TrimSpace(string(body))
if msg == "" {
return nil, fmt.Errorf("token endpoint returned %d", resp.StatusCode)
}
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, msg)
tokenErr := &TokenError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
_ = json.Unmarshal(body, tokenErr)
return nil, tokenErr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The 512-byte body limit can hide the error code and defeat Terminal().

The reader truncates the body at 512 bytes. If a provider returns a longer error body, json.Unmarshal fails, the error is discarded, and Code stays empty. Terminal() then returns false, so refreshContextToken in clientcmd/api_client.go classifies a real invalid_grant as an unknown failure. The dead refresh token stays on disk and every later command fails with the same error instead of prompting a new login.

Raise the limit so normal OAuth error documents fit.

🐛 Proposed fix
 	if resp.StatusCode != http.StatusOK {
-		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
 		tokenErr := &TokenError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
 		_ = json.Unmarshal(body, tokenErr)
 		return nil, tokenErr
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
msg := strings.TrimSpace(string(body))
if msg == "" {
return nil, fmt.Errorf("token endpoint returned %d", resp.StatusCode)
}
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, msg)
tokenErr := &TokenError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
_ = json.Unmarshal(body, tokenErr)
return nil, tokenErr
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
tokenErr := &TokenError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
_ = json.Unmarshal(body, tokenErr)
return nil, tokenErr
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/oidcclient/oidcclient.go` around lines 142 - 146, Increase the
response-body read limit in the non-OK branch before constructing and
unmarshalling TokenError, using a size sufficient for normal OAuth error
documents so the error code is preserved and Terminal() can classify
invalid_grant correctly. Keep the existing TokenError construction and return
flow unchanged.

Comment thread clientcmd/api_client.go
Comment on lines +59 to +68
cfg, err := LoadConfig()
if err != nil {
logger.Debugf("failed to refresh OIDC token for context %q server %s: %v", mcCtx.Name, mcCtx.Server, err)
if mcCtx.Token != "" && mcCtx.Token != previousAccessToken {
return mcCtx.Token, nil
}
return "", fmt.Errorf("refresh OIDC token for %s: %w", mcCtx.Server, err)
return err
}
store, err := cfg.store()
if err != nil {
return err
}

logger.Debugf("refreshed OIDC token for context %q server %s", mcCtx.Name, mcCtx.Server)
mcCtx.SetOIDCTokens(refreshed)
if cfg, err := LoadConfig(); err == nil {
updateContextOIDCTokens(cfg, mcCtx.Name, refreshed)
} else {
return "", fmt.Errorf("failed to update context OIDC tokens: %w", err)
return credentials.WithLock(configDir(), func() error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

The configuration is read outside credentials.WithLock and then used for decisions and writes inside it. refreshContextToken loads the config and selects the credential store before it acquires the cross-process lock. Everything the locked block does afterwards, including the endpoint-cache write, is based on that pre-lock snapshot. A concurrent process that changes credential_store or any context in the window causes a write to the wrong store or an overwrite of the other process' config changes.

  • clientcmd/api_client.go#L59-L68: move the LoadConfig and cfg.store() calls into the function passed to credentials.WithLock, so the store selection matches the state observed under the lock.
  • clientcmd/whoami_token.go#L64-L69: keep saveConfigLocked but require the caller to pass a config that was loaded under the lock; the endpoint-cache write then no longer discards concurrent context changes.
📍 Affects 2 files
  • clientcmd/api_client.go#L59-L68 (this comment)
  • clientcmd/whoami_token.go#L64-L69
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/api_client.go` around lines 59 - 68, The refreshContextToken flow
in clientcmd/api_client.go must load the configuration and select cfg.store()
inside the callback passed to credentials.WithLock, so all subsequent decisions
and writes use state observed under the lock. In clientcmd/whoami_token.go,
retain saveConfigLocked but ensure its caller passes the configuration loaded
within that locked callback, preventing endpoint-cache persistence from
overwriting concurrent context changes.

Comment thread clientcmd/api_client.go
Comment on lines +68 to +84
return credentials.WithLock(configDir(), func() error {
cred, err := store.Get(mcCtx.Name)
if err != nil {
return err
}
if cred.IsZero() {
cred = mcCtx.credential()
}
mcCtx.applyCredential(cred)

if cred.NeedsReauth != "" {
return mcCtx.ReauthError()
}
if cred.OIDC == nil || cred.OIDC.RefreshToken == "" {
mcCtx.NeedsReauth = "no refresh token"
return mcCtx.ReauthError()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -e go . clientcmd | head -50
echo "== credential() and applyCredential"
ast-grep run --lang go --pattern 'func (c *MCContext) credential() $$$' clientcmd || true
rg -nP -C6 'func \(c \*MCContext\) (credential|applyCredential)\s*\(' clientcmd
echo "== WithLock implementation"
rg -nP -C15 'func WithLock\s*\(' clientcmd/credentials
echo "== callers of WithLock"
rg -nP -C3 'WithLock\s*\(' --type=go
echo "== saveConfigLocked / store.Set / Writable lock usage"
rg -nP -C10 'func saveConfigLocked\s*\(' clientcmd
rg -nP -C6 'func \(s \*(FileStore|KeychainStore)\) (Set|Get|Writable)\s*\(' clientcmd/credentials

Repository: flanksource/mission-control

Length of output: 5582


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== clientcmd/api_client.go relevant lines"
cat -n clientcmd/api_client.go | sed -n '50,110p'

echo "== clientcmd/credentials/file.go lock/store methods"
cat -n clientcmd/credentials/file.go | sed -n '1,260p'

echo "== clientcmd/credentials/lock.go full"
cat -n clientcmd/credentials/lock.go | sed -n '1,140p'

echo "== contextTokenEndpoint definition/usages"
rg -n -C8 'contextTokenEndpoint' --type=go

echo "== lock helper exports and WithLock caller sites"
rg -n -C4 'WithLock\(|Unlock\(|F*Lock\(' clientcmd --type=go

Repository: flanksource/mission-control

Length of output: 10221


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== clientcmd/credentials/store.go"
cat -n clientcmd/credentials/store.go | sed -n '1,220p'

echo "== all token endpoint names/usages"
rg -n -C8 'TokenEndpoint|tokenEndpoint|contextToken|RefreshToken' --type=go

echo "== all WithLock call sites"
rg -n -C4 '\bWithLock\s*\(' --type=go

echo "== all lock Unlock usage"
rg -n -C4 '\.Unlock\(' clientcmd --type=go

Repository: flanksource/mission-control

Length of output: 3225


Ensure refreshContextToken is called through a non-nil MCContext.

mcCtx.credential() returns nil for nil receivers, and cred.OIDC is dereferenced inside WithLock. The nil guard at line 73 does not cover this path because IsZero() accepts nil credentials. The store methods called here do not acquire another WithLock, so the refreshed read-modify-write under the existing lock is fine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/api_client.go` around lines 68 - 84, Ensure the refresh flow around
refreshContextToken uses a non-nil MCContext before calling mcCtx.credential(),
applying credentials, or accessing credential fields inside
credentials.WithLock. Add the guard at the entry point while preserving the
existing locked read-modify-write behavior and reauthentication checks.

Comment thread clientcmd/refresh_test.go
Comment on lines +26 to +41
func newTokenEndpoint(respond func(w http.ResponseWriter, presented string)) *tokenEndpoint {
e := &tokenEndpoint{respond: respond}
e.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.URL.Path).To(Equal("/token"))
Expect(r.ParseForm()).To(Succeed())
Expect(r.Form.Get("grant_type")).To(Equal("refresh_token"))

presented := r.Form.Get("refresh_token")
e.mu.Lock()
e.requests = append(e.requests, presented)
e.mu.Unlock()

e.respond(w, presented)
}))
return e
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add defer ginkgo.GinkgoRecover() to the HTTP handler.

The handler runs in a goroutine owned by httptest.Server, not in the spec goroutine. If any Expect in the handler fails, Gomega calls ginkgo.Fail, which panics. Without GinkgoRecover in that goroutine, the panic aborts the whole suite instead of failing the spec. The same applies to the assertions that respond runs, for example writeRotatedTokens at Line 57 and the os.Chmod assertion at Line 176.

💚 Proposed fix
 	e.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		defer ginkgo.GinkgoRecover()
 		Expect(r.URL.Path).To(Equal("/token"))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func newTokenEndpoint(respond func(w http.ResponseWriter, presented string)) *tokenEndpoint {
e := &tokenEndpoint{respond: respond}
e.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.URL.Path).To(Equal("/token"))
Expect(r.ParseForm()).To(Succeed())
Expect(r.Form.Get("grant_type")).To(Equal("refresh_token"))
presented := r.Form.Get("refresh_token")
e.mu.Lock()
e.requests = append(e.requests, presented)
e.mu.Unlock()
e.respond(w, presented)
}))
return e
}
func newTokenEndpoint(respond func(w http.ResponseWriter, presented string)) *tokenEndpoint {
e := &tokenEndpoint{respond: respond}
e.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer ginkgo.GinkgoRecover()
Expect(r.URL.Path).To(Equal("/token"))
Expect(r.ParseForm()).To(Succeed())
Expect(r.Form.Get("grant_type")).To(Equal("refresh_token"))
presented := r.Form.Get("refresh_token")
e.mu.Lock()
e.requests = append(e.requests, presented)
e.mu.Unlock()
e.respond(w, presented)
}))
return e
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/refresh_test.go` around lines 26 - 41, Add defer
ginkgo.GinkgoRecover() at the start of the httptest server HTTP handler in
newTokenEndpoint, and in the handler-based assertion paths such as
writeRotatedTokens and the os.Chmod assertion, so Gomega failures from server
goroutines are recovered and reported to the spec.

Comment thread clientcmd/whoami_token.go
Comment on lines +53 to +70
func contextTokenEndpoint(cfg *MCConfig, mcCtx *MCContext) (string, error) {
if mcCtx.Endpoints != nil && mcCtx.Endpoints.TokenEndpoint != "" {
return mcCtx.Endpoints.TokenEndpoint, nil
}
ctx := cfg.GetContext(name)
if ctx == nil {
return

endpoints, err := discoverOIDCEndpoints(mcCtx.Server)
if err != nil {
return "", err
}
ctx.SetOIDCTokens(tokens)
if err := SaveConfig(cfg); err != nil {
fmt.Fprintf(os.Stderr, "failed to update context OIDC tokens: %v\n", err)
mcCtx.Endpoints = endpoints

if stored := cfg.GetContext(mcCtx.Name); stored != nil {
stored.Endpoints = endpoints
if err := saveConfigLocked(cfg); err != nil {
logger.Debugf("failed to cache OIDC endpoints for context %q: %v", mcCtx.Name, err)
}
}
return endpoints.TokenEndpoint, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject discovery results that carry no token endpoint.

discoverOIDCEndpoints returns the first candidate that answers with valid JSON. oidcclient.Discover does not require token_endpoint to be present, so a partial or unrelated JSON document produces a Discovery with an empty TokenEndpoint. Line 70 then returns "", nil, and refreshContextToken posts the refresh token to an empty URL. The empty endpoint is also cached in config.json, so every later refresh repeats the failure.

Fail early when the discovered token endpoint is empty.

🐛 Proposed fix
 	endpoints, err := discoverOIDCEndpoints(mcCtx.Server)
 	if err != nil {
 		return "", err
 	}
+	if endpoints.TokenEndpoint == "" {
+		return "", fmt.Errorf("OIDC discovery for %s returned no token_endpoint", mcCtx.Server)
+	}
 	mcCtx.Endpoints = endpoints
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func contextTokenEndpoint(cfg *MCConfig, mcCtx *MCContext) (string, error) {
if mcCtx.Endpoints != nil && mcCtx.Endpoints.TokenEndpoint != "" {
return mcCtx.Endpoints.TokenEndpoint, nil
}
ctx := cfg.GetContext(name)
if ctx == nil {
return
endpoints, err := discoverOIDCEndpoints(mcCtx.Server)
if err != nil {
return "", err
}
ctx.SetOIDCTokens(tokens)
if err := SaveConfig(cfg); err != nil {
fmt.Fprintf(os.Stderr, "failed to update context OIDC tokens: %v\n", err)
mcCtx.Endpoints = endpoints
if stored := cfg.GetContext(mcCtx.Name); stored != nil {
stored.Endpoints = endpoints
if err := saveConfigLocked(cfg); err != nil {
logger.Debugf("failed to cache OIDC endpoints for context %q: %v", mcCtx.Name, err)
}
}
return endpoints.TokenEndpoint, nil
func contextTokenEndpoint(cfg *MCConfig, mcCtx *MCContext) (string, error) {
if mcCtx.Endpoints != nil && mcCtx.Endpoints.TokenEndpoint != "" {
return mcCtx.Endpoints.TokenEndpoint, nil
}
endpoints, err := discoverOIDCEndpoints(mcCtx.Server)
if err != nil {
return "", err
}
if endpoints.TokenEndpoint == "" {
return "", fmt.Errorf("OIDC discovery for %s returned no token_endpoint", mcCtx.Server)
}
mcCtx.Endpoints = endpoints
if stored := cfg.GetContext(mcCtx.Name); stored != nil {
stored.Endpoints = endpoints
if err := saveConfigLocked(cfg); err != nil {
logger.Debugf("failed to cache OIDC endpoints for context %q: %v", mcCtx.Name, err)
}
}
return endpoints.TokenEndpoint, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientcmd/whoami_token.go` around lines 53 - 70, Update contextTokenEndpoint
to validate endpoints.TokenEndpoint immediately after discoverOIDCEndpoints
returns, returning an error when it is empty before assigning or caching the
discovery result. Preserve the existing endpoint caching and successful return
behavior for valid token endpoints.

Comment thread sdk/access_batch_test.go
Comment on lines +77 to +83
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request := requests.Add(1)
Expect(len(r.URL.RawQuery)).To(BeNumerically("<=", testAccessQueryLimit))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Range", "0-0/2")
_, _ = fmt.Fprintf(w, `[{"config_id":%q,"config_name":"config","external_user_id":%q,"user":"user-%02d","role":"Reader"}]`, config1, userOne, 100-request)
}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Ginkgo v2 GinkgoRecover required assertions in goroutines

💡 Result:

In Ginkgo v2, using defer GinkgoRecover() is required at the top of any goroutine that executes Ginkgo-related assertions or calls Fail() [1][2]. The reason for this requirement is that Ginkgo handles test failures by calling Fail(), which internally triggers a panic to immediately stop the execution of the current spec [1][2]. While Ginkgo can normally catch this panic to report the failure and safely transition to the next test, it cannot intercept panics originating from a different goroutine [3][4]. Without defer GinkgoRecover(), a failure inside a goroutine will cause the entire test suite to panic and crash [4][2]. When you place defer GinkgoRecover() at the start of a spawned goroutine, it provides a hook for Ginkgo to catch the panic, associate the failure with the correct spec, and prevent the entire suite from crashing [3][1]. Key best practices include: - Always use defer GinkgoRecover() in any goroutine where Gomega assertions or Fail() might be called [1][2]. - When waiting for a goroutine to complete in a test, ensure you use proper synchronization methods (like channels or Eventually matchers) to avoid blocking the main test thread indefinitely or leaking resources [2][5]. - If you find yourself frequently needing to add this to production code, it may be an indication to refactor the code to return errors instead of relying on panics or inline assertions [3][4]. For specialized cases, such as custom test helpers, Ginkgo provides tools like GinkgoHelper() to ensure that stack traces and failure locations are reported correctly [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the reviewed file context and relevant imports.
if [ -f sdk/access_batch_test.go ]; then
  echo "== file exists =="
  wc -l sdk/access_batch_test.go
  echo
  echo "== imports and target section =="
  sed -n '1,110p' sdk/access_batch_test.go | cat -n
else
  echo "sdk/access_batch_test.go not found"
  echo "matching files:"
  fd -i 'access_batch_test.go' .
fi

echo
echo "== imports from sdk/access_batch_test.go =="
rg -n '^import|^[[:space:]]*(ginkgo|gomega|httptest|http|net/http)/[^ (]|"github.com/(onsi/ginkgo/v2|onsi/gomega|flasksource/[^"]+)"' sdk/access_batch_test.go || true

echo
echo "== testAccessQueryLimit definition =="
rg -n 'testAccessQueryLimit|AccessQueryLimit|RAW_QUERY_LIMIT' sdk/access_batch_test.go sdk || true

echo
echo "== server HTTP handler context in reviewed file =="
rg -n -C 4 'httptest.NewServer|http.HandlerFunc|testAccessQueryLimit|GinkgoRecover|Expect\(len\(r\.URL\.RawQuery\)\)' sdk/access_batch_test.go || true

Repository: flanksource/mission-control

Length of output: 5928


Add defer ginkgo.GinkgoRecover() to the Gomega assertion handler.

The HTTP handler runs in a separate goroutine, and a Gomega assertion failing there still panics outside Ginkgo’s main goroutine. Add defer ginkgo.GinkgoRecover() at the top of this handler.

🐛 Proposed fix
 		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			defer ginkgo.GinkgoRecover()
 			request := requests.Add(1)
 			Expect(len(r.URL.RawQuery)).To(BeNumerically("<=", testAccessQueryLimit))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request := requests.Add(1)
Expect(len(r.URL.RawQuery)).To(BeNumerically("<=", testAccessQueryLimit))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Range", "0-0/2")
_, _ = fmt.Fprintf(w, `[{"config_id":%q,"config_name":"config","external_user_id":%q,"user":"user-%02d","role":"Reader"}]`, config1, userOne, 100-request)
}))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer ginkgo.GinkgoRecover()
request := requests.Add(1)
Expect(len(r.URL.RawQuery)).To(BeNumerically("<=", testAccessQueryLimit))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Range", "0-0/2")
_, _ = fmt.Fprintf(w, `[{"config_id":%q,"config_name":"config","external_user_id":%q,"user":"user-%02d","role":"Reader"}]`, config1, userOne, 100-request)
}))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_batch_test.go` around lines 77 - 83, Add defer
ginkgo.GinkgoRecover() at the start of the httptest HTTP handler passed to
http.HandlerFunc, before the request counter and Gomega assertion, so assertion
failures from the handler goroutine are recovered by Ginkgo.

Comment thread sdk/access_identity.go
Comment on lines +375 to +381
func nameOrEmailFilter(value string) string {
return fmt.Sprintf("(name.ilike.*%s*,email.ilike.*%s*,aliases.cs.{%s})", value, value, value)
}

func nameOrAliasFilter(value string) string {
return fmt.Sprintf("(name.ilike.*%s*,aliases.cs.{%s})", value, value)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Escape the interpolated value in the PostgREST or filter.

value goes straight into the or=(...) expression. A ,, (, ) or . in the argument changes the structure of the filter and adds or breaks disjuncts. Aliases and display names can contain these characters. The deleted_at filter is a separate AND parameter, so this does not widen access, but it produces wrong matches or opaque PostgREST errors.

Wrap each value in PostgREST double quotes and escape embedded quotes and backslashes.

🛠️ Proposed fix
+// pgQuote quotes a value for use inside a PostgREST logical filter, where
+// commas and parentheses are structural.
+func pgQuote(value string) string {
+	return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) + `"`
+}
+
 func nameOrEmailFilter(value string) string {
-	return fmt.Sprintf("(name.ilike.*%s*,email.ilike.*%s*,aliases.cs.{%s})", value, value, value)
+	quoted := pgQuote("*" + value + "*")
+	return fmt.Sprintf("(name.ilike.%s,email.ilike.%s,aliases.cs.{%s})", quoted, quoted, pgQuote(value))
 }
 
 func nameOrAliasFilter(value string) string {
-	return fmt.Sprintf("(name.ilike.*%s*,aliases.cs.{%s})", value, value)
+	return fmt.Sprintf("(name.ilike.%s,aliases.cs.{%s})", pgQuote("*"+value+"*"), pgQuote(value))
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func nameOrEmailFilter(value string) string {
return fmt.Sprintf("(name.ilike.*%s*,email.ilike.*%s*,aliases.cs.{%s})", value, value, value)
}
func nameOrAliasFilter(value string) string {
return fmt.Sprintf("(name.ilike.*%s*,aliases.cs.{%s})", value, value)
}
// pgQuote quotes a value for use inside a PostgREST logical filter, where
// commas and parentheses are structural.
func pgQuote(value string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) + `"`
}
func nameOrEmailFilter(value string) string {
quoted := pgQuote("*" + value + "*")
return fmt.Sprintf("(name.ilike.%s,email.ilike.%s,aliases.cs.{%s})", quoted, quoted, pgQuote(value))
}
func nameOrAliasFilter(value string) string {
return fmt.Sprintf("(name.ilike.%s,aliases.cs.{%s})", pgQuote("*"+value+"*"), pgQuote(value))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_identity.go` around lines 375 - 381, Update nameOrEmailFilter and
nameOrAliasFilter to escape each interpolated value for PostgREST filter syntax:
escape embedded backslashes and double quotes, then wrap the value in PostgREST
double quotes before inserting it into the ilike and aliases expressions. Reuse
the escaped value consistently across every occurrence.

Comment thread sdk/access_identity.go
Comment on lines +399 to +415
var out []T
for start := 0; start < len(ids); start += accessIDBatchSize {
end := min(start+accessIDBatchSize, len(ids))

batchParams := url.Values{}
for key, values := range params {
batchParams[key] = values
}
batchParams.Set(column, inList(ids[start:end]))

var batch []T
if _, err := c.pgGet(ctx, table, batchParams, &batch); err != nil {
return nil, err
}
out = append(out, batch...)
}
return out, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Merged results lose the global ordering across batches.

Each batch request carries order=name, so PostgREST sorts inside a batch only. out is the concatenation of the batches, so the final slice is not sorted globally. ListExternalUsers, ListExternalGroups and ListExternalRoles all pass order=name and the Faro commands default to --limit 500, so any name filter that matches more than accessIDBatchSize ids returns a visibly unsorted list.

Sort the merged slice after the loop. pgGetIn is generic, so accept an optional comparison function, or sort in each caller.

🛠️ Sketch of a fix
-func pgGetIn[T any](ctx context.Context, c *Client, table, column string, ids []string, params url.Values) ([]T, error) {
+func pgGetIn[T any](ctx context.Context, c *Client, table, column string, ids []string, params url.Values, less ...func(a, b T) int) ([]T, error) {
 	ids = uniqueIDs(ids)
@@
 		out = append(out, batch...)
 	}
+	if len(less) > 0 && len(ids) > accessIDBatchSize {
+		slices.SortStableFunc(out, less[0])
+	}
 	return out, nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_identity.go` around lines 399 - 415, Update the generic pgGetIn
batching flow to restore global ordering after concatenating batches: accept an
optional comparison function and sort the merged out slice after the loop when
provided. Pass the name comparison from ListExternalUsers, ListExternalGroups,
and ListExternalRoles, while preserving existing behavior for callers without
ordering requirements.

Comment thread sdk/access_test.go
Comment on lines +30 to +44
func pgRoutes(bodies map[string]string, seen map[string]url.Values) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Method).To(Equal(http.MethodGet))
table := strings.TrimPrefix(r.URL.Path, "/db/")
body, ok := bodies[table]
if !ok {
ginkgo.Fail("unexpected request: " + r.URL.Path)
}
if seen != nil {
seen[table] = r.URL.Query()
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assertions run on httptest handler goroutines without a Ginkgo recovery point. Both helpers call Expect or ginkgo.Fail inside the HTTP handler. The httptest server runs that handler on its own goroutine. Ginkgo reports a failure by panicking, so a failed assertion there crashes the suite instead of failing the spec, and the reported location is wrong.

  • sdk/access_test.go#L30-L44: add defer ginkgo.GinkgoRecover() as the first statement of the pgRoutes handler, and apply the same change to the inline handlers at lines 89-94, 105-111 and 415-421.
  • sdk/access_filter_test.go#L26-L41: add defer ginkgo.GinkgoRecover() as the first statement of the identityRoutes handler.
📍 Affects 2 files
  • sdk/access_test.go#L30-L44 (this comment)
  • sdk/access_filter_test.go#L26-L41
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access_test.go` around lines 30 - 44, Add defer ginkgo.GinkgoRecover() as
the first statement in the pgRoutes HTTP handler and the inline HTTP handlers at
sdk/access_test.go lines 89-94, 105-111, and 415-421. Also add it as the first
statement in the identityRoutes handler at sdk/access_filter_test.go lines
26-41, so assertions from server goroutines report through Ginkgo correctly.

Comment thread sdk/access.go
Comment on lines +67 to +69
if o.User != "" {
params.Set("or", fmt.Sprintf(`(user.ilike.*%s*,email.ilike.*%s*)`, o.User, o.User))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

PostgREST filter value double quotes escaping reserved characters comma parenthesis

💡 Result:

To handle PostgREST reserved characters in filter values—specifically commas (,), periods (.), colons (:), asterisks (*), and parentheses (, )—you must enclose the value in double quotes [1][2]. For example, to filter a value containing a comma, use:?name=in.(%22Hebdon,John%22,%22Williams,Mary%22) [1][3] If your HTTP client or library automatically encodes URLs (such as axios), you can often use double quotes directly instead of their percent-encoded form (%22):?name=in.("Hebdon,John","Williams,Mary") [1][2] Escaping Double Quotes and Backslashes: If a value itself contains a double quote character, you can escape it using a backslash ("). A literal backslash can be represented by a double backslash (\). Note that when used in a URL, these should be percent-encoded (e.g., %5C for backslash) [1][2]. Example for escaping special characters within a value:?name=in.(%22Quote:%5C%22%22,%22Backslash:%5C%5C%22) [1][2] This results in the values: Quote:" and Backslash:\ [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map and inspect the relevant Go file and nearby call sites.
git ls-files | rg '(^|/)access\.go$|sdk/access\.go' || true
printf '\n--- sdk/access.go outline ---\n'
ast-grep outline sdk/access.go --view compact || true
printf '\n--- sdk/access.go lines 1-140 ---\n'
sed -n '1,140p' sdk/access.go | nl -ba

printf '\n--- references to User / ilike / PostgREST in sdk/access.go ---\n'
rg -n 'User|ilike|PostgREST|or|params\.Set|fmt\.Sprintf' sdk/access.go || true

Repository: flanksource/mission-control

Length of output: 479


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- sdk/access.go first 180 lines ---\n'
sed -n '1,180p' sdk/access.go

printf '\n--- references to User / ilike / PostgREST in sdk/access.go ---\n'
rg -n 'User|ilike|PostgREST|or|params\.Set|fmt\.Sprintf' sdk/access.go || true

printf '\n--- call sites for sdk User filter ---\n'
rg -n 'access|User:\s*|\.User' . --glob '*.go' --glob '!vendor/**' --glob '!node_modules/**' | head -200

Repository: flanksource/mission-control

Length of output: 256


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- sdk/access.go first 180 lines ---'
sed -n '1,180p' sdk/access.go

printf '%s\n' ''
printf '%s\n' '--- references to User / ilike / PostgREST in sdk/access.go ---'
rg -n 'User|ilike|PostgREST|or|params\.Set|fmt\.Sprintf' sdk/access.go || true

printf '%s\n' ''
printf '%s\n' '--- call sites for sdk User filter ---'
rg -n 'access|User:\s*|\.User' . --glob '*.go' --glob '!vendor/**' --glob '!node_modules/**' | head -200

Repository: flanksource/mission-control

Length of output: 28849


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- sdk/access.go pgGet implementation ---'
sed -n '294,370p' sdk/access.go

printf '%s\n' ''
printf '%s\n' '--- ListAccessGrants call locations ---'
rg -n 'ListAccessGrants|AccessGrantOptions|User:' . --glob '*.go' --glob '!vendor/**' --glob '!node_modules/**' | head -200

printf '%s\n' ''
python3 - <<'PY'
import urllib.parse

base = 'http://demo.postgrest.org/config_access_summary?deleted_at=is.null&order=config_name,user'
for user in ['alice', 'a,role.eq.Owner', 'a)eq.Owner', 'a.b']:
    params = urllib.parse.urlencode({
        'or': f'(user.ilike.*{user}*,email.ilike.*{user}*)',
    }, delimiter=';&')
    print(user, '=>', base + '&' + params)
PY

Repository: flanksource/mission-control

Length of output: 7614


Escape o.User in the PostgREST or filter.

ListAccessGrants sends o.User directly into user.ilike.*...*; values containing reserved characters such as ,, ), :, or . change the filter structure. Double-quote the wildcatted value and escape embedded quotes/backslashes.

🛠️ Proposed escaping helper
 	if o.User != "" {
-		params.Set("or", fmt.Sprintf(`(user.ilike.*%s*,email.ilike.*%s*)`, o.User, o.User))
+		pattern := quotePostgRESTValue("*" + o.User + "*")
+		params.Set("or", fmt.Sprintf(`(user.ilike.%s,email.ilike.%s)`, pattern, pattern))
 	}

Add the helper:

// quotePostgRESTValue double-quotes a filter value so reserved characters
// (",", ")", ".") are matched literally.
func quotePostgRESTValue(v string) string {
	return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"`
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if o.User != "" {
params.Set("or", fmt.Sprintf(`(user.ilike.*%s*,email.ilike.*%s*)`, o.User, o.User))
}
if o.User != "" {
pattern := quotePostgRESTValue("*" + o.User + "*")
params.Set("or", fmt.Sprintf(`(user.ilike.%s,email.ilike.%s)`, pattern, pattern))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/access.go` around lines 67 - 69, Update ListAccessGrants to pass the
o.User wildcard value through a quotePostgRESTValue helper before constructing
the PostgREST or filter. Add the helper using strings.NewReplacer to escape
backslashes and double quotes, then wrap the value in double quotes so reserved
characters remain literal.

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