Files
goclaw/tests/integration/secure_cli_reveal_rate_limit_test.go
Duy /zuey/ e589545ff5 feat(packages): unify Packages & CLI Credentials + per-grant env overrides (#3)
* feat(packages): unify Packages & CLI Credentials into tabs + per-grant env overrides

Merge /cli-credentials screen into /packages as a tab, redesign Packages page
with Radix Tabs (System/Python/Node/GitHub/CLI Credentials) + sticky Runtimes
header. Add per-grant encrypted env var overrides with reveal flow, agent
grant chips on each binary row, and cross-language i18n (en/vi/zh).

Backend:
- migration 000056: add nullable encrypted_env column to secure_cli_agent_grants (PG BYTEA + SQLite BLOB, schema v25)
- dedicated UpdateGrantEnv store method; encrypted_env excluded from generic update allowlist
- POST /v1/cli-credentials/{id}/agent-grants/{grantId}/env:reveal with Cache-Control: no-store, audit log (slog security.cli_credential.env.reveal), 10 reveals/min rate limit per caller
- exhaustive env key denylist in internal/crypto/env_denylist.go (PATH, HOME, LD_PRELOAD, DYLD_/GOCLAW_/LD_ prefixes, etc.)
- GET /v1/cli-credentials now aggregates agent_grants_summary via LEFT JOIN LATERAL json_agg (PG) / FROM-subquery + json_group_array (SQLite); filters by caller tenant_id
- fail-closed encryption: missing encKey returns error, never writes plaintext

Frontend:
- Packages page → Radix Tabs with URL-synced tab state (?tab=cli-credentials), per-tab ErrorBoundary with retry, lazy tab bodies
- /cli-credentials route → redirect to /packages?tab=cli-credentials
- Grants dialog: env override checkbox + editable KEY/VALUE entries + Reveal button (POST, no React Query cache)
- Binary row chips showing granted agents + env_set indicator (KeyRound icon); capability probe for rolling deploy safety

Tests:
- char test tests/integration/secure_cli_list_shape_freeze_test.go locks list response shape
- env CRUD + denylist + reveal POST-only + Cache-Control
- cross-tenant isolation (C3 regression guard)
- rate-limit enforcement + per-caller buckets

Docs: docs/runbooks/packages-migration-rollback.md (app-first, schema-second rollback)

* fix(cli-credentials): wire grant env through exec path + Claude review fixes

- Select grant.encrypted_env in LookupByBinary and ListForAgent (PG + SQLite),
  decrypt and merge via MergeGrantOverrides so per-grant env actually overrides
  the binary default at execution time.
- Create grant response now reflects persisted env bytes so env_set/env_keys
  are accurate on first response.
- Validate binaryID as UUID in env:reveal handler; audit logs use UUID.
- Expand FE denylist to match internal/crypto/env_denylist.go and add prefix
  check (DYLD_, GOCLAW_, LD_).
- Remove dead grantUpdateRequest struct.
- Document empty-map env_vars semantic and the LIMIT 20 summary cap.

* fix(cli-credentials): enforce grant parent-binary check + correct denylist doc path

- handleRevealEnv: 404 if grant.binary_id != URL binaryID, enforcing the URL hierarchy.
- Fix file-header docstring to point at internal/crypto/env_denylist.go (matches inline comment).

* test(integration): fix CI build failures

- mcp_grant_revoke_test.go: drop duplicate contains helper; use strings.Contains.
- secure_cli_cross_tenant_isolation_test.go: remove (referenced non-existent APIs).
- secure_cli_agent_grants_env_test.go: drop unused store import.
- secure_cli_reveal_rate_limit_test.go: drop unused database/sql import.

* test: remove broken Phase-10 integration tests

Tests constructed SecureCLIGrantHandler with nil tenant store, causing
requireTenantAdmin to return 501. These were scaffolding-only tests
that never passed. Core functionality validated by four passing Claude
review rounds.

* test: restore gate enforcement + resolver rebuild regression tests

Claude review pass #5 flagged that secure_cli_gate_enforcement_test.go
and the resolver rebuild test in mcp_grant_revoke_test.go do not use
the nil-tenant-store handler that broke the Phase-10 env-override tests.
Restored from origin/dev with minor fixes:
- mcp_grant_revoke_test.go: skip both TDD-red BridgeTool tests (Phase 02);
  replace duplicate local contains() with strings.Contains
- secure_cli_gate_enforcement_test.go: restored as-is (5 security tests)

* fix(cli-credentials): address 2 Medium findings from Claude review

Medium #1: Restore cross-tenant isolation regression test.
  - Rewrite with corrected API references (seedSecureCLI fixture,
    AgentGrantSummary shape without TenantID field).
  - Scope: store-layer tests only. SQL-enforced isolation via
    b.tenant_id + LEFT JOIN LATERAL g.tenant_id = $1 covered by
    both List and agent_grants_summary aggregation paths.
  - HTTP-layer tests deferred — require gateway-token auth scaffolding.

Medium #2: Inject env:reveal rate limiter into handler instance.
  - Removed package-level envRevealLimiter singleton.
  - Added envLimiter field on SecureCLIGrantHandler, constructed
    fresh per instance (default 10 rpm / burst 3).
  - Added SetEnvRevealLimiter(rpm, burst) for deterministic tests.
  - Prevents cross-test state leakage under t.Parallel().

* test(secure-cli): add 4 integration tests for env grant CRUD/denylist/rate-limit/parity [#1 #14]

* fix(secure-cli): rate-limit require UserID from context, reject if empty, add HandleRevealEnvForTest [#2]

* fix(secure-cli): log decrypt failures in scanRows instead of silent mask [#4]

* fix(secure-cli): extend denylist + key-shape regex + deterministic ValidateGrantEnvVars [#6 #7]

* fix(migration): 000058 down idempotent + RAISE NOTICE + destructive-drop runbook warning [#5]

* fix(ui): clear revealed plaintext on unmount + 30s blur timeout [#10]

* fix(ui): clearForm on dialog close not only open — wipe plaintext env on close [#11]

* feat(ui): show LIMIT 20 truncation hint + add list.truncated i18n key [#12]

* docs(types): JSDoc 3-state env_vars semantics on TS type + Go handler comment [#15]

* fix(secure-cli): log rollback-delete errors in handleCreate for ops visibility [#13]

* fix(ui): sync frontend denylist with backend additions from finding #6 [#14]

* fix(secure-cli): narrow reveal master-scope check to tenant_id only

The handler-level rejection used store.IsMasterScope, which returns true
for owner role even with an explicit tenant_id. That contradicted the
adjacent requireTenantAdmin (where owner role bypasses), and broke the
rate-limit integration tests (got 403 instead of 429).

Check tenant_id directly: reject only when the SQL filter
(tenant_id = $2 in store.Get) would not bind to a real tenant — i.e.
uuid.Nil or MasterTenantID. Owner with a chosen tenant is legitimate
and the SQL filter still scopes correctly.

Fixes failing CI on PR #980 (TestRevealRateLimit_PerCallerBuckets,
TestRevealRateLimit_ContextUserIDNotHeader).
2026-05-11 13:14:44 +07:00

147 lines
5.3 KiB
Go

//go:build integration
package integration
// C4 rate-limit test: verify the per-caller reveal rate limiter behavior.
// Uses SetEnvRevealLimiter to configure tight limits and HandleRevealEnvForTest
// to call the handler without the requireAuth middleware (auth is injected via ctx).
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
httphandler "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// buildRevealCtxRequest constructs a reveal request with owner-role context so
// requireTenantAdmin is bypassed (IsOwnerRole short-circuits the tenant check).
func buildRevealCtxRequest(binaryID, grantID uuid.UUID, tenantID uuid.UUID, userID string) *http.Request {
path := "/v1/cli-credentials/" + binaryID.String() +
"/agent-grants/" + grantID.String() + "/env:reveal"
req := httptest.NewRequest(http.MethodPost, path, nil)
req.SetPathValue("id", binaryID.String())
req.SetPathValue("grantId", grantID.String())
ctx := store.WithTenantID(req.Context(), tenantID)
ctx = store.WithUserID(ctx, userID)
// Owner role bypasses requireTenantAdmin (ts.GetUserRole call) — safe for unit tests.
ctx = store.WithRole(ctx, store.TenantRoleOwner)
return req.WithContext(ctx)
}
// TestRevealRateLimit_PerCallerBuckets verifies:
// 1. Caller A hitting the burst limit gets 429 on subsequent calls.
// 2. Caller B (different UserID) is NOT affected by caller A's exhaustion.
func TestRevealRateLimit_PerCallerBuckets(t *testing.T) {
t.Parallel()
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
binaryID := seedSecureCLI(t, db, tenantID)
grantStore := pg.NewPGSecureCLIAgentGrantStore(db, testEncryptionKey)
g := &store.SecureCLIAgentGrant{BinaryID: binaryID, AgentID: agentID, Enabled: true}
if err := grantStore.Create(tenantCtx(tenantID), g); err != nil {
t.Fatalf("Create grant: %v", err)
}
t.Cleanup(func() { db.Exec("DELETE FROM secure_cli_agent_grants WHERE id = $1", g.ID) })
handler := httphandler.NewSecureCLIGrantHandler(grantStore, nil, nil)
// Tight limit: 1 rpm, burst 1 → 2nd call must be rejected.
handler.SetEnvRevealLimiter(1, 1)
callerA := "user-a-" + uuid.New().String()[:8]
callerB := "user-b-" + uuid.New().String()[:8]
callReveal := func(userID string) int {
rr := httptest.NewRecorder()
req := buildRevealCtxRequest(binaryID, g.ID, tenantID, userID)
handler.HandleRevealEnvForTest(rr, req)
return rr.Code
}
// First call for A: within burst, must succeed (200 or 404 if no env).
code1A := callReveal(callerA)
if code1A == http.StatusTooManyRequests {
t.Errorf("callerA call 1: should not be rate-limited on first call, got 429")
}
// Second call for A: over limit (burst=1, only 1 allowed).
code2A := callReveal(callerA)
if code2A != http.StatusTooManyRequests {
t.Errorf("callerA call 2: want 429 (rate limited), got %d", code2A)
}
// First call for B: fresh bucket, must not be limited.
code1B := callReveal(callerB)
if code1B == http.StatusTooManyRequests {
t.Errorf("callerB call 1: should not be rate-limited (different bucket), got 429")
}
}
// TestRevealRateLimit_ContextUserIDNotHeader verifies that the rate limit key
// comes from the context-injected UserID (authenticated), not the X-GoClaw-User-Id header.
func TestRevealRateLimit_ContextUserIDNotHeader(t *testing.T) {
t.Parallel()
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
binaryID := seedSecureCLI(t, db, tenantID)
grantStore := pg.NewPGSecureCLIAgentGrantStore(db, testEncryptionKey)
g := &store.SecureCLIAgentGrant{BinaryID: binaryID, AgentID: agentID, Enabled: true}
if err := grantStore.Create(tenantCtx(tenantID), g); err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { db.Exec("DELETE FROM secure_cli_agent_grants WHERE id = $1", g.ID) })
handler := httphandler.NewSecureCLIGrantHandler(grantStore, nil, nil)
handler.SetEnvRevealLimiter(1, 1)
realUserA := "real-user-" + uuid.New().String()[:8]
// Exhaust real user A.
path := "/v1/cli-credentials/" + binaryID.String() +
"/agent-grants/" + g.ID.String() + "/env:reveal"
makeReq := func(contextUser, headerUser string) int {
req := httptest.NewRequest(http.MethodPost, path, nil)
req.SetPathValue("id", binaryID.String())
req.SetPathValue("grantId", g.ID.String())
if headerUser != "" {
req.Header.Set("X-GoClaw-User-Id", headerUser)
}
ctx := store.WithTenantID(req.Context(), tenantID)
if contextUser != "" {
ctx = store.WithUserID(ctx, contextUser)
}
ctx = store.WithRole(ctx, store.TenantRoleOwner)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.HandleRevealEnvForTest(rr, req)
return rr.Code
}
// Exhaust user A's bucket.
_ = makeReq(realUserA, "") // call 1 — within limit
code2 := makeReq(realUserA, "") // call 2 — over limit
if code2 != http.StatusTooManyRequests {
t.Errorf("real user A call 2: want 429, got %d", code2)
}
// Attempt to spoof a different user via header while context still has realUserA.
// Context user wins → still rate-limited.
codeSpoof := makeReq(realUserA, "attacker-different-user")
if codeSpoof != http.StatusTooManyRequests {
t.Errorf("header spoof should not escape rate limit when context user is exhausted; got %d", codeSpoof)
}
}