mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
* 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).
287 lines
9.0 KiB
Go
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
|