Files
goclaw/tests/integration/secure_cli_agent_grants_env_test.go
Duy /zuey/andGitHub 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

287 lines
9.0 KiB
Go

//go:build integration
package integration
// C4 coverage: per-grant env override store-layer tests.
// Covers: CRUD env override, denylist validation (via crypto package),
// 3-state semantics (absent/null/map), and the env_set/env_keys fields.
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// TestGrantEnv_SetAndReveal verifies that UpdateGrantEnv stores encrypted env
// and that Get returns the decrypted plaintext in g.EncryptedEnv.
func TestGrantEnv_SetAndReveal(t *testing.T) {
t.Parallel()
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
binaryID := seedSecureCLI(t, db, tenantID)
grantStore := pg.NewPGSecureCLIAgentGrantStore(db, testEncryptionKey)
// Create a bare grant (no env).
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) })
// Set env override.
plaintext := []byte(`{"MY_TOKEN":"secret123","MY_URL":"https://api.example.com"}`)
if err := grantStore.UpdateGrantEnv(tenantCtx(tenantID), g.ID, plaintext); err != nil {
t.Fatalf("UpdateGrantEnv: %v", err)
}
// Get must decrypt and return the plaintext in EncryptedEnv field.
fetched, err := grantStore.Get(tenantCtx(tenantID), g.ID)
if err != nil {
t.Fatalf("Get after UpdateGrantEnv: %v", err)
}
if string(fetched.EncryptedEnv) != string(plaintext) {
t.Errorf("Get.EncryptedEnv: want %s, got %s", plaintext, fetched.EncryptedEnv)
}
}
// TestGrantEnv_ClearWithNil verifies the 3-state null-clears semantics.
// Passing nil to UpdateGrantEnv removes the env override.
func TestGrantEnv_ClearWithNil(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) })
// Set env.
if err := grantStore.UpdateGrantEnv(tenantCtx(tenantID), g.ID, []byte(`{"KEY":"val"}`)); err != nil {
t.Fatalf("UpdateGrantEnv set: %v", err)
}
// Clear by passing nil.
if err := grantStore.UpdateGrantEnv(tenantCtx(tenantID), g.ID, nil); err != nil {
t.Fatalf("UpdateGrantEnv clear: %v", err)
}
fetched, err := grantStore.Get(tenantCtx(tenantID), g.ID)
if err != nil {
t.Fatalf("Get after clear: %v", err)
}
if len(fetched.EncryptedEnv) > 0 {
t.Errorf("expected empty EncryptedEnv after clear, got %q", fetched.EncryptedEnv)
}
}
// TestGrantEnv_DenylistRejection verifies that IsDeniedEnvKey correctly rejects
// entries from the denylist (backend enforcement via crypto package).
func TestGrantEnv_DenylistRejection(t *testing.T) {
cases := []struct {
key string
denied bool
}{
{"PATH", true},
{"LD_PRELOAD", true},
{"DYLD_INSERT_LIBRARIES", true},
{"GOCLAW_SECRET", true},
{"MY_TOKEN", false},
{"AWS_ACCESS_KEY_ID", false},
{"NODE_OPTIONS", true},
{"PYTHONPATH", true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.key, func(t *testing.T) {
got := crypto.IsDeniedEnvKey(tc.key)
if got != tc.denied {
t.Errorf("IsDeniedEnvKey(%q) = %v, want %v", tc.key, got, tc.denied)
}
})
}
}
// TestGrantEnv_ValidateGrantEnvVars_DeniedKeysReported verifies that ValidateGrantEnvVars
// returns all denied keys in rejectedKeys (not silently drops them).
func TestGrantEnv_ValidateGrantEnvVars_DeniedKeysReported(t *testing.T) {
envVars := map[string]string{
"MY_SAFE_KEY": "value",
"PATH": "/bin",
"HOME": "/root",
}
rejected, valErr := crypto.ValidateGrantEnvVars(envVars)
if valErr != nil {
t.Fatalf("unexpected valErr: %v", valErr)
}
if len(rejected) != 2 {
t.Errorf("expected 2 rejected keys (PATH, HOME), got %d: %v", len(rejected), rejected)
}
deniedSet := make(map[string]bool)
for _, k := range rejected {
deniedSet[k] = true
}
if !deniedSet["PATH"] {
t.Error("PATH should be in rejected keys")
}
if !deniedSet["HOME"] {
t.Error("HOME should be in rejected keys")
}
}
// TestGrantEnv_ListReflectsPresence verifies that ListByBinary decrypts env
// and that env presence is detectable from EncryptedEnv field length.
func TestGrantEnv_ListReflectsPresence(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) })
if err := grantStore.UpdateGrantEnv(tenantCtx(tenantID), g.ID, []byte(`{"MY_KEY":"val"}`)); err != nil {
t.Fatalf("UpdateGrantEnv: %v", err)
}
grants, err := grantStore.ListByBinary(tenantCtx(tenantID), binaryID)
if err != nil {
t.Fatalf("ListByBinary: %v", err)
}
if len(grants) == 0 {
t.Fatal("expected at least one grant")
}
var found *store.SecureCLIAgentGrant
for i := range grants {
if grants[i].ID == g.ID {
found = &grants[i]
break
}
}
if found == nil {
t.Fatalf("grant %s not found in ListByBinary", g.ID)
}
// After list, EncryptedEnv should contain decrypted data (store decrypts on scan).
if len(found.EncryptedEnv) == 0 {
t.Error("ListByBinary: EncryptedEnv should be populated (decrypted) when env exists")
}
}
// TestGrantEnv_DeterministicValidationOrder verifies that ValidateGrantEnvVars
// produces deterministic error output when multiple denied keys are present.
func TestGrantEnv_DeterministicValidationOrder(t *testing.T) {
envVars := map[string]string{
"PATH": "/bin",
"HOME": "/root",
"MY_KEY": "ok",
"USER": "root",
"SHELL": "/bin/bash",
}
rejected1, _ := crypto.ValidateGrantEnvVars(envVars)
rejected2, _ := crypto.ValidateGrantEnvVars(envVars)
if len(rejected1) != len(rejected2) {
t.Errorf("non-deterministic: call 1 returned %d rejected keys, call 2 returned %d",
len(rejected1), len(rejected2))
}
set1 := make(map[string]bool)
for _, k := range rejected1 {
set1[k] = true
}
for _, k := range rejected2 {
if !set1[k] {
t.Errorf("non-deterministic: key %q in call 2 but not call 1", k)
}
}
}
// TestGrantEnv_RevealDecryptedValue verifies the crypto round-trip that the
// reveal handler relies on: store.Get decrypts, caller parses as string map.
func TestGrantEnv_RevealDecryptedValue(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) })
secret := `{"API_KEY":"super-secret-value","ENDPOINT":"https://api.example.com"}`
if err := grantStore.UpdateGrantEnv(tenantCtx(tenantID), g.ID, []byte(secret)); err != nil {
t.Fatalf("UpdateGrantEnv: %v", err)
}
// Simulate reveal: Get decrypts, then caller parses as map.
fetched, err := grantStore.Get(tenantCtx(tenantID), g.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(fetched.EncryptedEnv) != secret {
t.Errorf("reveal: want %s, got %s", secret, fetched.EncryptedEnv)
}
var envMap map[string]string
if err := json.Unmarshal(fetched.EncryptedEnv, &envMap); err != nil {
t.Errorf("reveal result not valid JSON map: %v", err)
}
if envMap["API_KEY"] != "super-secret-value" {
t.Errorf("wrong API_KEY value: %q", envMap["API_KEY"])
}
}
// TestGrantEnv_GrantNotFoundCrossID verifies that Get with wrong tenant returns no row,
// enforcing tenant isolation for the reveal path.
func TestGrantEnv_GrantNotFoundCrossID(t *testing.T) {
t.Parallel()
db := testDB(t)
tenantA, agentA := seedTenantAgent(t, db)
binaryA := seedSecureCLI(t, db, tenantA)
tenantB, _ := seedTenantAgent(t, db)
grantStore := pg.NewPGSecureCLIAgentGrantStore(db, testEncryptionKey)
g := &store.SecureCLIAgentGrant{BinaryID: binaryA, AgentID: agentA, Enabled: true}
if err := grantStore.Create(tenantCtx(tenantA), g); err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { db.Exec("DELETE FROM secure_cli_agent_grants WHERE id = $1", g.ID) })
// Tenant B trying to Get tenant A's grant must fail.
_, err := grantStore.Get(tenantCtx(tenantB), g.ID)
if err == nil {
t.Error("Get with wrong tenant should return error (ErrNoRows), got nil")
}
}
// Ensure uuid is used (referenced in TestGrantEnv_GrantNotFoundCrossID via uuid.UUID fields).
var _ = uuid.Nil