mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
feat(cli-credentials): support per-agent env grants
- enforce binary/grant parent checks on nested grant routes - validate grant binary/agent tenant scope on create - fail closed on invalid per-user env and preserve per-user precedence - remove duplicate CLI Credentials sidebar entry while keeping Packages tab route - refs #12
This commit is contained in:
1 parent
425cecb9a3
commit
c029e4f6bf
11 files changed
+560
-79
No files matched your search
@@ -137,6 +137,33 @@ func validateAndSerializeEnvVars(w http.ResponseWriter, locale string, envVars m
|
||||
return b, true
|
||||
}
|
||||
|
||||
func parseGrantPathIDs(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, uuid.UUID, bool) {
|
||||
binaryID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")})
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
grantID, err := uuid.Parse(r.PathValue("grantId"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")})
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return binaryID, grantID, true
|
||||
}
|
||||
|
||||
func (h *SecureCLIGrantHandler) getGrantForBinary(w http.ResponseWriter, r *http.Request, locale string) (*store.SecureCLIAgentGrant, uuid.UUID, bool) {
|
||||
binaryID, grantID, ok := parseGrantPathIDs(w, r, locale)
|
||||
if !ok {
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
g, err := h.grants.Get(r.Context(), grantID)
|
||||
if err != nil || g.BinaryID != binaryID {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "grant", grantID.String())})
|
||||
return nil, uuid.Nil, false
|
||||
}
|
||||
return g, binaryID, true
|
||||
}
|
||||
|
||||
func (h *SecureCLIGrantHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTenantAdmin(w, r, h.tenantStore) {
|
||||
return
|
||||
@@ -180,6 +207,22 @@ func (h *SecureCLIGrantHandler) handleCreate(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "agent_id")})
|
||||
return
|
||||
}
|
||||
if exists, err := h.grants.BinaryExists(r.Context(), binaryID); err != nil {
|
||||
slog.Error("secure_cli_grants.create.binary_scope", "binary_id", binaryID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate credential")})
|
||||
return
|
||||
} else if !exists {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", binaryID.String())})
|
||||
return
|
||||
}
|
||||
if exists, err := h.grants.AgentExists(r.Context(), req.AgentID); err != nil {
|
||||
slog.Error("secure_cli_grants.create.agent_scope", "agent_id", req.AgentID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate agent")})
|
||||
return
|
||||
} else if !exists {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", req.AgentID.String())})
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
@@ -243,14 +286,8 @@ func (h *SecureCLIGrantHandler) handleGet(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
grantID, err := uuid.Parse(r.PathValue("grantId"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")})
|
||||
return
|
||||
}
|
||||
g, err := h.grants.Get(r.Context(), grantID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "grant", grantID.String())})
|
||||
g, _, ok := h.getGrantForBinary(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
populateGrantEnvFields(g)
|
||||
@@ -262,9 +299,8 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
grantID, err := uuid.Parse(r.PathValue("grantId"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")})
|
||||
g, binaryID, ok := h.getGrantForBinary(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -298,16 +334,13 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
|
||||
updates[k] = decoded
|
||||
}
|
||||
}
|
||||
if err := h.grants.Update(r.Context(), grantID, updates); err != nil {
|
||||
slog.Error("secure_cli_grants.update", "grant_id", grantID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "update grant")})
|
||||
return
|
||||
}
|
||||
|
||||
// 3-state env_vars semantics: absent=skip, null=clear, {...}=replace.
|
||||
// Finding #15: {} (empty map) is treated as clear — same as null.
|
||||
// TS type: absent | null | Record<string,string> — see ui/web/src/types/cli-credential.ts.
|
||||
var envJSON []byte
|
||||
envPresent := false
|
||||
if envRaw, present := raw["env_vars"]; present {
|
||||
envPresent = true
|
||||
var envPtr *map[string]string
|
||||
if string(envRaw) != "null" {
|
||||
var m map[string]string
|
||||
@@ -320,7 +353,6 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
|
||||
// envPtr == nil → clear; envPtr != nil → replace.
|
||||
// Note: envPtr pointing to an empty map ({}) is treated as clear (same as null) —
|
||||
// envJSON stays nil and UpdateGrantEnv(nil) removes the override.
|
||||
var envJSON []byte
|
||||
if envPtr != nil && len(*envPtr) > 0 {
|
||||
j, ok := validateAndSerializeEnvVars(w, locale, *envPtr)
|
||||
if !ok {
|
||||
@@ -328,14 +360,23 @@ func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
envJSON = j
|
||||
}
|
||||
if err := h.grants.UpdateGrantEnv(r.Context(), grantID, envJSON); err != nil {
|
||||
slog.Error("secure_cli_grants.update.set_env", "grant_id", grantID, "error", err)
|
||||
}
|
||||
|
||||
if err := h.grants.Update(r.Context(), g.ID, updates); err != nil {
|
||||
slog.Error("secure_cli_grants.update", "grant_id", g.ID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "update grant")})
|
||||
return
|
||||
}
|
||||
|
||||
if envPresent {
|
||||
if err := h.grants.UpdateGrantEnv(r.Context(), g.ID, envJSON); err != nil {
|
||||
slog.Error("secure_cli_grants.update.set_env", "grant_id", g.ID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "update grant env")})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.emitCacheInvalidate(r.PathValue("id"))
|
||||
h.emitCacheInvalidate(binaryID.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -344,18 +385,17 @@ func (h *SecureCLIGrantHandler) handleDelete(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
grantID, err := uuid.Parse(r.PathValue("grantId"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")})
|
||||
g, binaryID, ok := h.getGrantForBinary(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.grants.Delete(r.Context(), grantID); err != nil {
|
||||
slog.Error("secure_cli_grants.delete", "grant_id", grantID, "error", err)
|
||||
if err := h.grants.Delete(r.Context(), g.ID); err != nil {
|
||||
slog.Error("secure_cli_grants.delete", "grant_id", g.ID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "delete grant")})
|
||||
return
|
||||
}
|
||||
|
||||
h.emitCacheInvalidate(r.PathValue("id"))
|
||||
h.emitCacheInvalidate(binaryID.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -407,26 +447,9 @@ func (h *SecureCLIGrantHandler) handleRevealEnv(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
grantID, err := uuid.Parse(r.PathValue("grantId"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "grant")})
|
||||
return
|
||||
}
|
||||
binaryID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "binary")})
|
||||
return
|
||||
}
|
||||
|
||||
// store.Get enforces tenant_id = $2 filter (non-cross-tenant context).
|
||||
g, err := h.grants.Get(ctx, grantID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "grant", grantID.String())})
|
||||
return
|
||||
}
|
||||
// Enforce URL parent-child hierarchy: grant must belong to binaryID in path.
|
||||
if g.BinaryID != binaryID {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "grant", grantID.String())})
|
||||
// store.Get enforces tenant_id filter; helper also enforces URL parent-child hierarchy.
|
||||
g, binaryID, ok := h.getGrantForBinary(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -438,7 +461,7 @@ func (h *SecureCLIGrantHandler) handleRevealEnv(w http.ResponseWriter, r *http.R
|
||||
slog.Info("audit.cli_credential.env.reveal",
|
||||
"caller_id", callerID,
|
||||
"tenant_id", tenantID,
|
||||
"grant_id", grantID,
|
||||
"grant_id", g.ID,
|
||||
"binary_id", binaryID,
|
||||
"reason", "reveal-env",
|
||||
"ts", time.Now().UTC(),
|
||||
@@ -455,7 +478,7 @@ func (h *SecureCLIGrantHandler) handleRevealEnv(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
var envVars map[string]string
|
||||
if err := json.Unmarshal(g.EncryptedEnv, &envVars); err != nil {
|
||||
slog.Error("secure_cli_grants.reveal.parse", "grant_id", grantID, "error", err)
|
||||
slog.Error("secure_cli_grants.reveal.parse", "grant_id", g.ID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "parse grant env")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type fakeSecureCLIGrantStore struct {
|
||||
binaries map[uuid.UUID]bool
|
||||
agents map[uuid.UUID]bool
|
||||
grants map[uuid.UUID]*store.SecureCLIAgentGrant
|
||||
|
||||
createCalled bool
|
||||
updateCalled bool
|
||||
deleteCalled bool
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) BinaryExists(_ context.Context, id uuid.UUID) (bool, error) {
|
||||
return s.binaries[id], nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) AgentExists(_ context.Context, id uuid.UUID) (bool, error) {
|
||||
return s.agents[id], nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) Create(_ context.Context, g *store.SecureCLIAgentGrant) error {
|
||||
s.createCalled = true
|
||||
if g.ID == uuid.Nil {
|
||||
g.ID = store.GenNewID()
|
||||
}
|
||||
s.grants[g.ID] = g
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) Get(_ context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) {
|
||||
if g := s.grants[id]; g != nil {
|
||||
cp := *g
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) Update(context.Context, uuid.UUID, map[string]any) error {
|
||||
s.updateCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) Delete(context.Context, uuid.UUID) error {
|
||||
s.deleteCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) ListByBinary(context.Context, uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) ListByAgent(context.Context, uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *fakeSecureCLIGrantStore) UpdateGrantEnv(context.Context, uuid.UUID, []byte) error {
|
||||
s.updateCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestWithGrantPath(method string, body io.Reader, binaryID, grantID uuid.UUID) (*httptest.ResponseRecorder, *http.Request) {
|
||||
req := httptest.NewRequest(method, "/v1/cli-credentials/"+binaryID.String()+"/agent-grants/"+grantID.String(), body)
|
||||
req.SetPathValue("id", binaryID.String())
|
||||
req.SetPathValue("grantId", grantID.String())
|
||||
ctx := store.WithTenantID(req.Context(), uuid.MustParse("0193a5b0-7000-7000-8000-000000000002"))
|
||||
ctx = store.WithRole(ctx, store.RoleOwner)
|
||||
ctx = store.WithUserID(ctx, "admin@example.com")
|
||||
return httptest.NewRecorder(), req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func requestWithBinaryPath(body io.Reader, binaryID uuid.UUID) (*httptest.ResponseRecorder, *http.Request) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/cli-credentials/"+binaryID.String()+"/agent-grants", body)
|
||||
req.SetPathValue("id", binaryID.String())
|
||||
ctx := store.WithTenantID(req.Context(), uuid.MustParse("0193a5b0-7000-7000-8000-000000000002"))
|
||||
ctx = store.WithRole(ctx, store.RoleOwner)
|
||||
ctx = store.WithUserID(ctx, "admin@example.com")
|
||||
return httptest.NewRecorder(), req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestSecureCLIGrantNestedRoutesRejectWrongBinaryParent(t *testing.T) {
|
||||
realBinaryID := uuid.New()
|
||||
pathBinaryID := uuid.New()
|
||||
grantID := uuid.New()
|
||||
fake := &fakeSecureCLIGrantStore{
|
||||
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
|
||||
grantID: {
|
||||
BaseModel: store.BaseModel{ID: grantID},
|
||||
BinaryID: realBinaryID,
|
||||
AgentID: uuid.New(),
|
||||
Enabled: true,
|
||||
EncryptedEnv: []byte(`{"TOKEN":"value"}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
h := NewSecureCLIGrantHandler(fake, nil, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
call func(http.ResponseWriter, *http.Request)
|
||||
}{
|
||||
{name: "get", method: http.MethodGet, call: h.handleGet},
|
||||
{name: "update", method: http.MethodPut, body: `{"enabled":false}`, call: h.handleUpdate},
|
||||
{name: "delete", method: http.MethodDelete, call: h.handleDelete},
|
||||
{name: "reveal", method: http.MethodPost, call: h.handleRevealEnv},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake.updateCalled = false
|
||||
fake.deleteCalled = false
|
||||
rr, req := requestWithGrantPath(tt.method, strings.NewReader(tt.body), pathBinaryID, grantID)
|
||||
tt.call(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for wrong binary parent, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if fake.updateCalled {
|
||||
t.Fatal("wrong-parent request must not update grant or env")
|
||||
}
|
||||
if fake.deleteCalled {
|
||||
t.Fatal("wrong-parent request must not delete grant")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureCLIGrantCreateValidatesBinaryAndAgentScope(t *testing.T) {
|
||||
binaryID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
binaryOK bool
|
||||
agentOK bool
|
||||
wantStatus int
|
||||
wantCreate bool
|
||||
}{
|
||||
{name: "missing binary", binaryOK: false, agentOK: true, wantStatus: http.StatusNotFound},
|
||||
{name: "missing agent", binaryOK: true, agentOK: false, wantStatus: http.StatusNotFound},
|
||||
{name: "valid scope", binaryOK: true, agentOK: true, wantStatus: http.StatusCreated, wantCreate: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &fakeSecureCLIGrantStore{
|
||||
binaries: map[uuid.UUID]bool{binaryID: tt.binaryOK},
|
||||
agents: map[uuid.UUID]bool{agentID: tt.agentOK},
|
||||
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{},
|
||||
}
|
||||
h := NewSecureCLIGrantHandler(fake, nil, nil)
|
||||
rr, req := requestWithBinaryPath(strings.NewReader(`{"agent_id":"`+agentID.String()+`","enabled":true}`), binaryID)
|
||||
|
||||
h.handleCreate(rr, req)
|
||||
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("expected status %d, got %d body=%s", tt.wantStatus, rr.Code, rr.Body.String())
|
||||
}
|
||||
if fake.createCalled != tt.wantCreate {
|
||||
t.Fatalf("createCalled=%v, want %v", fake.createCalled, tt.wantCreate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureCLIGrantUpdateRejectsInvalidEnvVarsBeforeScalarUpdate(t *testing.T) {
|
||||
binaryID := uuid.New()
|
||||
grantID := uuid.New()
|
||||
fake := &fakeSecureCLIGrantStore{
|
||||
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
|
||||
grantID: {
|
||||
BaseModel: store.BaseModel{ID: grantID},
|
||||
BinaryID: binaryID,
|
||||
AgentID: uuid.New(),
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
h := NewSecureCLIGrantHandler(fake, nil, nil)
|
||||
rr, req := requestWithGrantPath(http.MethodPut, strings.NewReader(`{"enabled":false,"env_vars":123}`), binaryID, grantID)
|
||||
|
||||
h.handleUpdate(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if fake.updateCalled {
|
||||
t.Fatal("invalid env_vars request must not persist scalar grant updates")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,42 @@ func NewPGSecureCLIAgentGrantStore(db *sql.DB, encKey string) *PGSecureCLIAgentG
|
||||
|
||||
const grantSelectCols = `id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, encrypted_env, created_at, updated_at`
|
||||
|
||||
func (s *PGSecureCLIAgentGrantStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) {
|
||||
query := `SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = $1`
|
||||
args := []any{binaryID}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
query += ` AND tenant_id = $2`
|
||||
args = append(args, tid)
|
||||
}
|
||||
query += `)`
|
||||
|
||||
var exists bool
|
||||
err := s.db.QueryRowContext(ctx, query, args...).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIAgentGrantStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) {
|
||||
query := `SELECT EXISTS(SELECT 1 FROM agents WHERE id = $1 AND deleted_at IS NULL`
|
||||
args := []any{agentID}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
query += ` AND tenant_id = $2`
|
||||
args = append(args, tid)
|
||||
}
|
||||
query += `)`
|
||||
|
||||
var exists bool
|
||||
err := s.db.QueryRowContext(ctx, query, args...).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *PGSecureCLIAgentGrantStore) Create(ctx context.Context, g *store.SecureCLIAgentGrant) error {
|
||||
if g.ID == uuid.Nil {
|
||||
g.ID = store.GenNewID()
|
||||
|
||||
@@ -26,11 +26,11 @@ type SecureCLIBinary struct {
|
||||
BinaryName string `json:"binary_name" db:"binary_name"`
|
||||
BinaryPath *string `json:"binary_path,omitempty" db:"binary_path"`
|
||||
Description string `json:"description" db:"description"`
|
||||
EncryptedEnv []byte `json:"-" db:"encrypted_env"` // AES-256-GCM encrypted JSON — never serialized to API
|
||||
EncryptedEnv []byte `json:"-" db:"encrypted_env"` // AES-256-GCM encrypted JSON — never serialized to API
|
||||
DenyArgs json.RawMessage `json:"deny_args" db:"deny_args"` // regex patterns for blocked subcommands
|
||||
DenyVerbose json.RawMessage `json:"deny_verbose" db:"deny_verbose"` // blocked verbose/debug flags
|
||||
DenyVerbose json.RawMessage `json:"deny_verbose" db:"deny_verbose"` // blocked verbose/debug flags
|
||||
TimeoutSeconds int `json:"timeout_seconds" db:"timeout_seconds"`
|
||||
Tips string `json:"tips" db:"tips"` // hint injected into TOOLS.md context
|
||||
Tips string `json:"tips" db:"tips"` // hint injected into TOOLS.md context
|
||||
IsGlobal bool `json:"is_global" db:"is_global"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
@@ -67,12 +67,12 @@ func (b *SecureCLIBinary) MergeGrantOverrides(g *SecureCLIAgentGrant) {
|
||||
|
||||
// SecureCLIUserCredential holds per-user encrypted env overrides for a binary.
|
||||
type SecureCLIUserCredential struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
BinaryID uuid.UUID `json:"binary_id" db:"binary_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"`
|
||||
CreatedAt string `json:"created_at" db:"created_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
BinaryID uuid.UUID `json:"binary_id" db:"binary_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"`
|
||||
CreatedAt string `json:"created_at" db:"created_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
// EncryptedEnv is decrypted JSON — never serialized to API.
|
||||
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
|
||||
}
|
||||
@@ -89,13 +89,13 @@ type SecureCLIAgentGrant struct {
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
// EncryptedEnv holds per-grant AES-256-GCM encrypted env vars. NULL means no override.
|
||||
// Never serialized to API — HTTP layer exposes env_keys + env_set only.
|
||||
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
|
||||
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
|
||||
// EnvKeys is populated by HTTP handlers only (sorted key names, no values). Not a DB column.
|
||||
EnvKeys []string `json:"env_keys,omitempty" db:"-"`
|
||||
EnvKeys []string `json:"env_keys,omitempty" db:"-"`
|
||||
// EnvSet indicates whether this grant has an env override. Not a DB column.
|
||||
EnvSet bool `json:"env_set" db:"-"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
EnvSet bool `json:"env_set" db:"-"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// SecureCLIStore manages secure CLI binary credential configurations.
|
||||
@@ -137,6 +137,8 @@ type SecureCLIStore interface {
|
||||
|
||||
// SecureCLIAgentGrantStore manages per-agent grants for secure CLI binaries.
|
||||
type SecureCLIAgentGrantStore interface {
|
||||
BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error)
|
||||
AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error)
|
||||
Create(ctx context.Context, g *SecureCLIAgentGrant) error
|
||||
Get(ctx context.Context, id uuid.UUID) (*SecureCLIAgentGrant, error)
|
||||
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
|
||||
|
||||
@@ -887,6 +887,15 @@ func EnsureSchema(db *sql.DB) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("sqlite: missing migration for version %d → %d", v, v+1)
|
||||
}
|
||||
if tableName, columnName, ok := idempotentColumnMigration(v); ok {
|
||||
hasColumn, err := sqliteColumnExists(db, tableName, columnName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s.%s: %w", tableName, columnName, err)
|
||||
}
|
||||
if hasColumn {
|
||||
patch = `SELECT 1;`
|
||||
}
|
||||
}
|
||||
// Migrations that rebuild a table referenced by another table's FK
|
||||
// require foreign_keys=OFF per SQLite altertable §7. The pragma is
|
||||
// a no-op inside a transaction, so toggle it around BEGIN/COMMIT.
|
||||
@@ -953,6 +962,44 @@ func EnsureSchema(db *sql.DB) error {
|
||||
return seedMasterTenant(db)
|
||||
}
|
||||
|
||||
func idempotentColumnMigration(version int) (string, string, bool) {
|
||||
switch version {
|
||||
case 26:
|
||||
return "secure_cli_agent_grants", "encrypted_env", true
|
||||
case 28:
|
||||
return "webhook_calls", "lease_token", true
|
||||
case 29:
|
||||
return "webhooks", "encrypted_secret", true
|
||||
case 33:
|
||||
return "agents", "model_fallback", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteColumnExists(db *sql.DB, tableName, columnName string) (bool, error) {
|
||||
rows, err := db.Query("PRAGMA table_info(" + tableName + ")")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == columnName {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// seedMasterTenant ensures the master tenant row exists (idempotent).
|
||||
func seedMasterTenant(db *sql.DB) error {
|
||||
_, err := db.Exec(
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -29,6 +29,42 @@ func NewSQLiteSecureCLIAgentGrantStore(db *sql.DB, encKey string) *SQLiteSecureC
|
||||
|
||||
const grantSelectCols = `id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, encrypted_env, created_at, updated_at`
|
||||
|
||||
func (s *SQLiteSecureCLIAgentGrantStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) {
|
||||
query := `SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = ?`
|
||||
args := []any{binaryID}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
query += ` AND tenant_id = ?`
|
||||
args = append(args, tid)
|
||||
}
|
||||
query += `)`
|
||||
|
||||
var exists bool
|
||||
err := s.db.QueryRowContext(ctx, query, args...).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIAgentGrantStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) {
|
||||
query := `SELECT EXISTS(SELECT 1 FROM agents WHERE id = ? AND deleted_at IS NULL`
|
||||
args := []any{agentID}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
query += ` AND tenant_id = ?`
|
||||
args = append(args, tid)
|
||||
}
|
||||
query += `)`
|
||||
|
||||
var exists bool
|
||||
err := s.db.QueryRowContext(ctx, query, args...).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIAgentGrantStore) Create(ctx context.Context, g *store.SecureCLIAgentGrant) error {
|
||||
if g.ID == uuid.Nil {
|
||||
g.ID = store.GenNewID()
|
||||
|
||||
@@ -373,20 +373,11 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
|
||||
return credentialedDenyError(binary, args, p)
|
||||
}
|
||||
|
||||
// Step 4: Decrypt env vars from store (already decrypted by store layer)
|
||||
envMap := make(map[string]string)
|
||||
if len(cred.EncryptedEnv) > 0 {
|
||||
if err := json.Unmarshal(cred.EncryptedEnv, &envMap); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("credentialed exec: invalid env JSON for %q: %v", binary, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4b: Merge per-user env overrides (user takes priority over base)
|
||||
if len(cred.UserEnv) > 0 {
|
||||
var userEnvMap map[string]string
|
||||
if err := json.Unmarshal(cred.UserEnv, &userEnvMap); err == nil {
|
||||
maps.Copy(envMap, userEnvMap)
|
||||
}
|
||||
// Step 4: Decrypt env vars from store (already decrypted by store layer).
|
||||
// Per-user env overrides take priority over binary/grant env.
|
||||
envMap, err := mergeCredentialedEnv(cred)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("credentialed exec: invalid env JSON for %q: %v", binary, err))
|
||||
}
|
||||
|
||||
// Step 5: Register credential values for output scrubbing
|
||||
@@ -407,6 +398,26 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
|
||||
return t.executeCredentialedHost(ctx, absPath, args, cwd, envMap, timeout)
|
||||
}
|
||||
|
||||
func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error) {
|
||||
envMap := make(map[string]string)
|
||||
if cred == nil {
|
||||
return envMap, nil
|
||||
}
|
||||
if len(cred.EncryptedEnv) > 0 {
|
||||
if err := json.Unmarshal(cred.EncryptedEnv, &envMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(cred.UserEnv) > 0 {
|
||||
var userEnvMap map[string]string
|
||||
if err := json.Unmarshal(cred.UserEnv, &userEnvMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maps.Copy(envMap, userEnvMap)
|
||||
}
|
||||
return envMap, nil
|
||||
}
|
||||
|
||||
// executeCredentialedHost runs a credentialed command directly on the host.
|
||||
// Uses exec.Command (no shell) with credentials as env vars.
|
||||
// ctx cancellation triggers SIGTERM → 3s grace → SIGKILL via process-group helpers.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestMergeCredentialedEnvPerUserOverridesGrantEnv(t *testing.T) {
|
||||
binary := &store.SecureCLIBinary{
|
||||
EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`),
|
||||
}
|
||||
binary.MergeGrantOverrides(&store.SecureCLIAgentGrant{
|
||||
EncryptedEnv: []byte(`{"SHARED_KEY":"grant","GRANT_ONLY":"agent"}`),
|
||||
})
|
||||
binary.UserEnv = []byte(`{"SHARED_KEY":"user","USER_ONLY":"personal"}`)
|
||||
|
||||
env, err := mergeCredentialedEnv(binary)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeCredentialedEnv returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := env["SHARED_KEY"]; got != "user" {
|
||||
t.Fatalf("expected per-user env to win for duplicate key, got %q", got)
|
||||
}
|
||||
if got := env["GRANT_ONLY"]; got != "agent" {
|
||||
t.Fatalf("expected grant env key to remain, got %q", got)
|
||||
}
|
||||
if got := env["USER_ONLY"]; got != "personal" {
|
||||
t.Fatalf("expected per-user env key to remain, got %q", got)
|
||||
}
|
||||
if _, ok := env["BINARY_ONLY"]; ok {
|
||||
t.Fatal("expected agent grant env to replace binary default env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCredentialedEnvFailsClosedOnInvalidUserEnv(t *testing.T) {
|
||||
_, err := mergeCredentialedEnv(&store.SecureCLIBinary{
|
||||
EncryptedEnv: []byte(`{"SHARED_KEY":"grant"}`),
|
||||
UserEnv: []byte(`{broken json`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid per-user env JSON to fail closed")
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,6 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
<SidebarItem to={ROUTES.TENANTS} icon={Building2} label={t("nav.tenants")} collapsed={collapsed} />
|
||||
)}
|
||||
<SidebarItem to={ROUTES.PROVIDERS} icon={Cpu} label={t("nav.providers")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CLI_CREDENTIALS} icon={KeyRound} label={t("nav.cliCredentials")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.API_KEYS} icon={KeyRound} label={t("nav.apiKeys")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.PACKAGES} icon={Blocks} label={t("nav.packages")} collapsed={collapsed} />
|
||||
{isOwner && (
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEnvVarsPayload,
|
||||
EMPTY_ENV_STATE,
|
||||
envStateFromGrant,
|
||||
} from "../cli-credential-grants-dialog-helpers";
|
||||
import type { CLIAgentGrant } from "../hooks/use-cli-credentials";
|
||||
|
||||
describe("cli credential grant env helpers", () => {
|
||||
it("omits env_vars when existing masked values are not revealed", () => {
|
||||
const payload = buildEnvVarsPayload(
|
||||
{ overrideEnabled: true, entries: [{ key: "TOKEN", value: "", masked: true }] },
|
||||
true,
|
||||
);
|
||||
expect(payload).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializes only visible env entries", () => {
|
||||
const payload = buildEnvVarsPayload(
|
||||
{
|
||||
overrideEnabled: true,
|
||||
entries: [
|
||||
{ key: " CLI_ENV ", value: "agent-value", masked: false },
|
||||
{ key: "", value: "ignored", masked: false },
|
||||
{ key: "MASKED", value: "", masked: true },
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
expect(payload).toEqual({ CLI_ENV: "agent-value" });
|
||||
});
|
||||
|
||||
it("clears existing env override when override is disabled", () => {
|
||||
expect(buildEnvVarsPayload(EMPTY_ENV_STATE, true)).toBeNull();
|
||||
expect(buildEnvVarsPayload(EMPTY_ENV_STATE, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives masked state from grant env metadata without values", () => {
|
||||
const state = envStateFromGrant({
|
||||
env_set: true,
|
||||
env_keys: ["API_KEY", "TOKEN"],
|
||||
} as CLIAgentGrant);
|
||||
|
||||
expect(state).toEqual({
|
||||
overrideEnabled: true,
|
||||
entries: [
|
||||
{ key: "API_KEY", value: "", masked: true },
|
||||
{ key: "TOKEN", value: "", masked: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
}
|
||||
|
||||
describe("CLI Credentials package routing", () => {
|
||||
it("keeps CLI Credentials inside Packages and out of the left sidebar", () => {
|
||||
const sidebar = source("src/components/layout/sidebar.tsx");
|
||||
const packagesPage = source("src/pages/packages/packages-page.tsx");
|
||||
|
||||
expect(sidebar).not.toContain("ROUTES.CLI_CREDENTIALS");
|
||||
expect(sidebar).not.toContain("nav.cliCredentials");
|
||||
expect(packagesPage).toContain('"cli-credentials"');
|
||||
expect(packagesPage).toContain("CliCredentialsTab");
|
||||
});
|
||||
|
||||
it("keeps the legacy /cli-credentials route as a redirect to the Packages tab", () => {
|
||||
const routes = source("src/routes.tsx");
|
||||
|
||||
expect(routes).toContain("ROUTES.CLI_CREDENTIALS");
|
||||
expect(routes).toContain("/packages?tab=cli-credentials");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user