feat(secure-cli): per-agent grants with setting overrides

Replace agent_id column on secure_cli_binaries with is_global flag
and new secure_cli_agent_grants table for per-agent access control
with optional deny_args, deny_verbose, timeout_seconds, tips overrides.

- Migration 000036: create grants table, migrate agent-specific rows,
  dedup binaries, drop agent_id, add is_global
- Store layer: SecureCLIAgentGrantStore interface + PG implementation,
  LookupByBinary with LEFT JOIN grant merge, ListForAgent
- HTTP API: CRUD endpoints at /v1/cli-credentials/{id}/agent-grants
- Agent loop: buildCredentialCLIContext uses ListForAgent for scoped
  system prompt (agents only see authorized CLIs)
- Web UI: grants dialog with card list + inline form, is_global toggle
  replaces agent dropdown, i18n for en/vi/zh
This commit is contained in:
viettranx committed 2026-04-04 13:18:57 +07:00
1 parent 29f06cc24b
commit 156b2dd96c
28 files changed
+1395 -188

No files matched your search

+4 -1
View File
@@ -356,7 +356,7 @@ func runGateway() {
mcpToolLister = mcpMgr
}
httpapi.InitGatewayToken(cfg.Gateway.Token)
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
if providersH != nil {
providersH.SetAPIBaseFallback(cfg.Providers.APIBaseForType)
}
@@ -412,6 +412,9 @@ func runGateway() {
if secureCLIH != nil {
server.SetSecureCLIHandler(secureCLIH)
}
if secureCLIGrantH != nil {
server.SetSecureCLIGrantHandler(secureCLIGrantH)
}
// Activity audit log API
if pgStores.Activity != nil {
+6 -2
View File
@@ -9,7 +9,7 @@ import (
)
// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages).
func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.MCPUserCredentialsHandler) {
func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
@@ -19,6 +19,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
var builtinToolsH *httpapi.BuiltinToolsHandler
var pendingMessagesH *httpapi.PendingMessagesHandler
var secureCLIH *httpapi.SecureCLIHandler
var secureCLIGrantH *httpapi.SecureCLIGrantHandler
if stores != nil && stores.Agents != nil {
var summoner *httpapi.AgentSummoner
@@ -91,6 +92,9 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
if stores != nil && stores.SecureCLI != nil {
secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus)
}
if stores != nil && stores.SecureCLIGrants != nil {
secureCLIGrantH = httpapi.NewSecureCLIGrantHandler(stores.SecureCLIGrants, msgBus)
}
return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH
return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH
}
+9 -2
View File
@@ -66,12 +66,19 @@ func (l *Loop) filteredToolNamesForChannel(channelType string) []string {
}
// buildCredentialCLIContext generates the TOOLS.md supplement for credentialed CLIs.
// Returns empty string if no secure CLI store is configured or no enabled CLIs.
// Uses agent-scoped list when agent UUID is available: returns only global CLIs
// plus explicitly granted CLIs, with grant overrides merged.
func (l *Loop) buildCredentialCLIContext(ctx context.Context) string {
if l.secureCLIStore == nil {
return ""
}
creds, err := l.secureCLIStore.ListEnabled(ctx)
var creds []store.SecureCLIBinary
var err error
if l.agentUUID != uuid.Nil {
creds, err = l.secureCLIStore.ListForAgent(ctx, l.agentUUID)
} else {
creds, err = l.secureCLIStore.ListEnabled(ctx)
}
if err != nil || len(creds) == 0 {
return ""
}
+5
View File
@@ -431,6 +431,11 @@ func (s *Server) SetSecureCLIHandler(h *httpapi.SecureCLIHandler) {
s.handlers = append(s.handlers, h)
}
// SetSecureCLIGrantHandler sets the per-agent secure CLI grant handler.
func (s *Server) SetSecureCLIGrantHandler(h *httpapi.SecureCLIGrantHandler) {
s.handlers = append(s.handlers, h)
}
// SetPackagesHandler sets the runtime package management handler.
func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) {
s.handlers = append(s.handlers, h)
+3 -3
View File
@@ -160,7 +160,7 @@ type secureCLICreateRequest struct {
DenyVerbose json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
Tips string `json:"tips,omitempty"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
IsGlobal *bool `json:"is_global,omitempty"`
Enabled bool `json:"enabled"`
}
@@ -224,7 +224,7 @@ func (h *SecureCLIHandler) handleCreate(w http.ResponseWriter, r *http.Request)
DenyVerbose: req.DenyVerbose,
TimeoutSeconds: req.TimeoutSeconds,
Tips: req.Tips,
AgentID: req.AgentID,
IsGlobal: req.IsGlobal == nil || *req.IsGlobal, // default true
Enabled: req.Enabled,
CreatedBy: store.UserIDFromContext(r.Context()),
}
@@ -282,7 +282,7 @@ func (h *SecureCLIHandler) handleUpdate(w http.ResponseWriter, r *http.Request)
allowed := map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true,
"timeout_seconds": true, "tips": true, "is_global": true, "enabled": true,
}
for k := range updates {
if !allowed[k] {
+174
View File
@@ -0,0 +1,174 @@
package http
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// SecureCLIGrantHandler handles CRUD for per-agent secure CLI grants.
type SecureCLIGrantHandler struct {
grants store.SecureCLIAgentGrantStore
msgBus *bus.MessageBus
}
func NewSecureCLIGrantHandler(gs store.SecureCLIAgentGrantStore, msgBus *bus.MessageBus) *SecureCLIGrantHandler {
return &SecureCLIGrantHandler{grants: gs, msgBus: msgBus}
}
// RegisterRoutes registers agent grant routes nested under cli-credentials.
func (h *SecureCLIGrantHandler) RegisterRoutes(mux *http.ServeMux) {
auth := func(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(permissions.RoleAdmin, next)
}
mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-grants", auth(h.handleList))
mux.HandleFunc("POST /v1/cli-credentials/{id}/agent-grants", auth(h.handleCreate))
mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleGet))
mux.HandleFunc("PUT /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleUpdate))
mux.HandleFunc("DELETE /v1/cli-credentials/{id}/agent-grants/{grantId}", auth(h.handleDelete))
}
func (h *SecureCLIGrantHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
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
}
grants, err := h.grants.ListByBinary(r.Context(), binaryID)
if err != nil {
slog.Error("secure_cli_grants.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "grants")})
return
}
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
}
type grantCreateRequest struct {
AgentID uuid.UUID `json:"agent_id"`
DenyArgs *json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
Tips *string `json:"tips,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (h *SecureCLIGrantHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
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
}
var req grantCreateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.AgentID == uuid.Nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "agent_id")})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
g := &store.SecureCLIAgentGrant{
BinaryID: binaryID,
AgentID: req.AgentID,
DenyArgs: req.DenyArgs,
DenyVerbose: req.DenyVerbose,
TimeoutSeconds: req.TimeoutSeconds,
Tips: req.Tips,
Enabled: enabled,
}
if err := h.grants.Create(r.Context(), g); err != nil {
slog.Error("secure_cli_grants.create", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
h.emitCacheInvalidate(binaryID.String())
writeJSON(w, http.StatusCreated, g)
}
func (h *SecureCLIGrantHandler) handleGet(w http.ResponseWriter, r *http.Request) {
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())})
return
}
writeJSON(w, http.StatusOK, g)
}
func (h *SecureCLIGrantHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
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
}
var updates map[string]any
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
updates["updated_at"] = time.Now()
if err := h.grants.Update(r.Context(), grantID, updates); err != nil {
slog.Error("secure_cli_grants.update", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
binaryID := r.PathValue("id")
h.emitCacheInvalidate(binaryID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *SecureCLIGrantHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
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
}
if err := h.grants.Delete(r.Context(), grantID); err != nil {
slog.Error("secure_cli_grants.delete", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
binaryID := r.PathValue("id")
h.emitCacheInvalidate(binaryID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *SecureCLIGrantHandler) emitCacheInvalidate(key string) {
if h.msgBus == nil {
return
}
h.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: map[string]any{"scope": "secure_cli", "key": key},
})
}
+3 -2
View File
@@ -42,8 +42,9 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
Contacts: NewPGContactStore(db),
Activity: NewPGActivityStore(db),
Snapshots: NewPGSnapshotStore(db),
SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey),
APIKeys: NewPGAPIKeyStore(db),
SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey),
SecureCLIGrants: NewPGSecureCLIAgentGrantStore(db),
APIKeys: NewPGAPIKeyStore(db),
Heartbeats: NewPGHeartbeatStore(db),
ConfigPermissions: NewPGConfigPermissionStore(db),
Tenants: NewPGTenantStore(db),
+189 -108
View File
@@ -3,6 +3,7 @@ package pg
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -24,12 +25,12 @@ func NewPGSecureCLIStore(db *sql.DB, encryptionKey string) *PGSecureCLIStore {
}
const secureCLISelectCols = `id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at`
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at`
// secureCLISelectColsAliased is the same as secureCLISelectCols but prefixed with table alias "b."
// secureCLISelectColsAliased is prefixed with table alias "b."
// Required for LookupByBinary which uses LEFT JOIN (ambiguous column names without prefix).
const secureCLISelectColsAliased = `b.id, b.binary_name, b.binary_path, b.description, b.encrypted_env,
b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.agent_id, b.enabled, b.created_by, b.created_at, b.updated_at`
b.deny_args, b.deny_verbose, b.timeout_seconds, b.tips, b.is_global, b.enabled, b.created_by, b.created_at, b.updated_at`
func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error {
if err := store.ValidateUserID(b.CreatedBy); err != nil {
@@ -62,13 +63,13 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary)
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at, tenant_id)
deny_args, deny_verbose, timeout_seconds, tips, is_global, enabled, created_by, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description,
envBytes,
jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose),
b.TimeoutSeconds, b.Tips,
nilUUID(b.AgentID), b.Enabled,
b.IsGlobal, b.Enabled,
b.CreatedBy, now, now, tenantID,
)
return err
@@ -92,14 +93,13 @@ func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.Secure
func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error) {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
err := row.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
@@ -107,7 +107,6 @@ func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error)
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
@@ -136,21 +135,19 @@ func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, er
for rows.Next() {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
if err := rows.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
); err != nil {
continue
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
@@ -171,16 +168,14 @@ func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, er
}
// secureCLIAllowedFields is the allowlist of columns that can be updated via execMapUpdate.
// Defense-in-depth: prevents column name injection even if caller skips validation.
var secureCLIAllowedFields = map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"encrypted_env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true,
"timeout_seconds": true, "tips": true, "is_global": true, "enabled": true,
"updated_at": true,
}
func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
// Filter unknown fields to prevent column name injection
for k := range updates {
if !secureCLIAllowedFields[k] {
delete(updates, k)
@@ -232,7 +227,7 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e
query += ` WHERE tenant_id = $1`
qArgs = append(qArgs, tenantID)
}
query += ` ORDER BY binary_name, agent_id NULLS LAST`
query += ` ORDER BY binary_name`
rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
@@ -240,34 +235,9 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e
return s.scanRows(rows)
}
func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
if store.IsCrossTenant(ctx) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true
ORDER BY binary_name, agent_id NULLS LAST`, agentID)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, nil
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true AND tenant_id = $2
ORDER BY binary_name, agent_id NULLS LAST`, agentID, tenantID)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
// LookupByBinary finds the best credential config for a binary name.
// Agent-specific config takes priority over global (agent_id IS NULL).
// If userID is non-empty, also fetches per-user env overrides via LEFT JOIN (zero extra queries).
// LookupByBinary finds the credential config for a binary name.
// Checks agent grant authorization and merges overrides if agentID is provided.
// Also fetches per-user env overrides via LEFT JOIN when userID is non-empty.
func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*store.SecureCLIBinary, error) {
tid := store.TenantIDFromContext(ctx)
isCross := store.IsCrossTenant(ctx)
@@ -275,104 +245,107 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string
return nil, nil
}
// Build query with optional LEFT JOIN for per-user credentials.
// Use aliased columns (b.) to avoid ambiguous column reference with JOIN.
// Build SELECT columns with optional LEFT JOINs for grant overrides and user env
selectCols := secureCLISelectColsAliased
joinClause := ""
grantCols := ", g.deny_args AS grant_deny_args, g.deny_verbose AS grant_deny_verbose, g.timeout_seconds AS grant_timeout, g.tips AS grant_tips, g.enabled AS grant_enabled, g.id AS grant_id"
selectCols += grantCols
var joinClause string
if userID != "" {
selectCols += ", uc.encrypted_env AS user_env"
joinClause = " LEFT JOIN secure_cli_user_credentials uc ON uc.binary_id = b.id AND uc.user_id = $%d AND uc.tenant_id = $%d"
} else {
selectCols += ", NULL AS user_env"
}
var args []any
var query string
argIdx := 1
// Base query
query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b`
// LEFT JOIN agent grant
if agentID != nil {
if userID != "" {
if isCross {
args = []any{binaryName, *agentID, userID, tid}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` +
fmt.Sprintf(joinClause, 3, 4) +
` WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true
ORDER BY b.agent_id NULLS LAST LIMIT 1`
} else {
args = []any{binaryName, *agentID, tid, userID, tid}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` +
fmt.Sprintf(joinClause, 4, 5) +
` WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true AND b.tenant_id = $3
ORDER BY b.agent_id NULLS LAST LIMIT 1`
}
} else {
if isCross {
args = []any{binaryName, *agentID}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b
WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true
ORDER BY b.agent_id NULLS LAST LIMIT 1`
} else {
args = []any{binaryName, *agentID, tid}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b
WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true AND b.tenant_id = $3
ORDER BY b.agent_id NULLS LAST LIMIT 1`
}
}
query += fmt.Sprintf(` LEFT JOIN secure_cli_agent_grants g ON g.binary_id = b.id AND g.agent_id = $%d`, argIdx)
args = append(args, *agentID)
argIdx++
} else {
if userID != "" {
if isCross {
args = []any{binaryName, userID, tid}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` +
fmt.Sprintf(joinClause, 2, 3) +
` WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true LIMIT 1`
} else {
args = []any{binaryName, tid, userID, tid}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` +
fmt.Sprintf(joinClause, 3, 4) +
` WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true AND b.tenant_id = $2 LIMIT 1`
}
} else {
if isCross {
args = []any{binaryName}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b
WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true LIMIT 1`
} else {
args = []any{binaryName, tid}
query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b
WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true AND b.tenant_id = $2 LIMIT 1`
}
}
query += ` LEFT JOIN secure_cli_agent_grants g ON FALSE` // never match
}
// LEFT JOIN user credentials
if userID != "" {
joinClause = fmt.Sprintf(` LEFT JOIN secure_cli_user_credentials uc ON uc.binary_id = b.id AND uc.user_id = $%d`, argIdx)
args = append(args, userID)
argIdx++
if !isCross {
joinClause += fmt.Sprintf(` AND uc.tenant_id = $%d`, argIdx)
args = append(args, tid)
argIdx++
}
query += joinClause
}
// WHERE clause
query += fmt.Sprintf(` WHERE b.binary_name = $%d AND b.enabled = true`, argIdx)
args = append(args, binaryName)
argIdx++
if !isCross {
query += fmt.Sprintf(` AND b.tenant_id = $%d`, argIdx)
args = append(args, tid)
argIdx++
}
// Authorization: global (no grant needed OR has enabled grant) OR non-global (must have enabled grant)
if agentID != nil {
query += ` AND (
(b.is_global = true AND (g.id IS NULL OR g.enabled = true))
OR
(b.is_global = false AND g.id IS NOT NULL AND g.enabled = true)
)`
} else {
// No agent context — only return global binaries
query += ` AND b.is_global = true`
}
query += ` LIMIT 1`
row := s.db.QueryRowContext(ctx, query, args...)
b, err := s.scanRowWithUserEnv(row)
if err == sql.ErrNoRows {
return nil, nil
}
return b, err
return s.scanRowWithGrantAndUserEnv(row)
}
// scanRowWithUserEnv scans a row that includes the extra user_env column from LEFT JOIN.
func (s *PGSecureCLIStore) scanRowWithUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) {
// scanRowWithGrantAndUserEnv scans a row that includes grant override columns and user_env.
func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
// Grant override columns (nullable)
var grantDenyArgs, grantDenyVerbose *[]byte
var grantTimeout *int
var grantTips *string
var grantEnabled *bool
var grantID *uuid.UUID
var userEnv []byte
err := row.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
// Grant columns
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID,
// User env
&userEnv,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
@@ -389,6 +362,22 @@ func (s *PGSecureCLIStore) scanRowWithUserEnv(row *sql.Row) (*store.SecureCLIBin
b.EncryptedEnv = env
}
// Apply grant overrides (if grant exists)
if grantID != nil {
grant := &store.SecureCLIAgentGrant{}
if grantDenyArgs != nil {
raw := json.RawMessage(*grantDenyArgs)
grant.DenyArgs = &raw
}
if grantDenyVerbose != nil {
raw := json.RawMessage(*grantDenyVerbose)
grant.DenyVerbose = &raw
}
grant.TimeoutSeconds = grantTimeout
grant.Tips = grantTips
b.MergeGrantOverrides(grant)
}
// Decrypt per-user env
if len(userEnv) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
@@ -417,3 +406,95 @@ func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBi
}
return s.scanRows(rows)
}
// ListForAgent returns all CLIs accessible by an agent (global + granted),
// with grant overrides merged into the returned configs.
func (s *PGSecureCLIStore) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
tid := store.TenantIDFromContext(ctx)
isCross := store.IsCrossTenant(ctx)
if !isCross && tid == uuid.Nil {
return nil, nil
}
selectCols := secureCLISelectColsAliased +
`, g.deny_args AS grant_deny_args, g.deny_verbose AS grant_deny_verbose,
g.timeout_seconds AS grant_timeout, g.tips AS grant_tips, g.id AS grant_id`
query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b
LEFT JOIN secure_cli_agent_grants g ON g.binary_id = b.id AND g.agent_id = $1
WHERE b.enabled = true
AND (
(b.is_global = true AND (g.id IS NULL OR g.enabled = true))
OR
(b.is_global = false AND g.id IS NOT NULL AND g.enabled = true)
)`
args := []any{agentID}
if !isCross {
query += ` AND b.tenant_id = $2`
args = append(args, tid)
}
query += ` ORDER BY b.binary_name`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.SecureCLIBinary
for rows.Next() {
var b store.SecureCLIBinary
var binaryPath *string
var denyArgs, denyVerbose *[]byte
var env []byte
var grantDenyArgs, grantDenyVerbose *[]byte
var grantTimeout *int
var grantTips *string
var grantID *uuid.UUID
if err := rows.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &b.IsGlobal,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantID,
); err != nil {
continue
}
b.BinaryPath = binaryPath
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
if denyVerbose != nil {
b.DenyVerbose = *denyVerbose
}
if len(env) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
b.EncryptedEnv = []byte(decrypted)
}
} else {
b.EncryptedEnv = env
}
// Apply grant overrides
if grantID != nil {
grant := &store.SecureCLIAgentGrant{}
if grantDenyArgs != nil {
raw := json.RawMessage(*grantDenyArgs)
grant.DenyArgs = &raw
}
if grantDenyVerbose != nil {
raw := json.RawMessage(*grantDenyVerbose)
grant.DenyVerbose = &raw
}
grant.TimeoutSeconds = grantTimeout
grant.Tips = grantTips
b.MergeGrantOverrides(grant)
}
result = append(result, b)
}
return result, nil
}
@@ -0,0 +1,200 @@
package pg
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGSecureCLIAgentGrantStore implements store.SecureCLIAgentGrantStore backed by Postgres.
type PGSecureCLIAgentGrantStore struct {
db *sql.DB
}
func NewPGSecureCLIAgentGrantStore(db *sql.DB) *PGSecureCLIAgentGrantStore {
return &PGSecureCLIAgentGrantStore{db: db}
}
const grantSelectCols = `id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, created_at, updated_at`
func (s *PGSecureCLIAgentGrantStore) Create(ctx context.Context, g *store.SecureCLIAgentGrant) error {
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
now := time.Now()
g.CreatedAt = now
g.UpdatedAt = now
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_agent_grants
(id, binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, tenant_id, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
g.ID, g.BinaryID, g.AgentID,
nullableJSON(g.DenyArgs), nullableJSON(g.DenyVerbose),
g.TimeoutSeconds, g.Tips,
g.Enabled, tenantID, now, now,
)
return err
}
func (s *PGSecureCLIAgentGrantStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) {
query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE id = $1`
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, sql.ErrNoRows
}
query += ` AND tenant_id = $2`
args = append(args, tid)
}
row := s.db.QueryRowContext(ctx, query, args...)
return s.scanRow(row)
}
var grantAllowedFields = map[string]bool{
"deny_args": true, "deny_verbose": true, "timeout_seconds": true,
"tips": true, "enabled": true, "updated_at": true,
}
func (s *PGSecureCLIAgentGrantStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
for k := range updates {
if !grantAllowedFields[k] {
delete(updates, k)
}
}
updates["updated_at"] = time.Now()
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "secure_cli_agent_grants", id, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
return execMapUpdateWhereTenant(ctx, s.db, "secure_cli_agent_grants", updates, id, tid)
}
func (s *PGSecureCLIAgentGrantStore) Delete(ctx context.Context, id uuid.UUID) error {
if store.IsCrossTenant(ctx) {
_, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_agent_grants WHERE id = $1", id)
return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
_, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_agent_grants WHERE id = $1 AND tenant_id = $2", id, tid)
return err
}
func (s *PGSecureCLIAgentGrantStore) ListByBinary(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE binary_id = $1`
args := []any{binaryID}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, nil
}
query += ` AND tenant_id = $2`
args = append(args, tid)
}
query += ` ORDER BY created_at`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
func (s *PGSecureCLIAgentGrantStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
query := `SELECT ` + grantSelectCols + ` FROM secure_cli_agent_grants WHERE agent_id = $1`
args := []any{agentID}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, nil
}
query += ` AND tenant_id = $2`
args = append(args, tid)
}
query += ` ORDER BY created_at`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
func (s *PGSecureCLIAgentGrantStore) scanRow(row *sql.Row) (*store.SecureCLIAgentGrant, error) {
var g store.SecureCLIAgentGrant
var denyArgs, denyVerbose *[]byte
var timeout *int
var tips *string
err := row.Scan(
&g.ID, &g.BinaryID, &g.AgentID,
&denyArgs, &denyVerbose, &timeout, &tips,
&g.Enabled, &g.CreatedAt, &g.UpdatedAt,
)
if err != nil {
return nil, err
}
s.applyNullable(&g, denyArgs, denyVerbose, timeout, tips)
return &g, nil
}
func (s *PGSecureCLIAgentGrantStore) scanRows(rows *sql.Rows) ([]store.SecureCLIAgentGrant, error) {
defer rows.Close()
var result []store.SecureCLIAgentGrant
for rows.Next() {
var g store.SecureCLIAgentGrant
var denyArgs, denyVerbose *[]byte
var timeout *int
var tips *string
if err := rows.Scan(
&g.ID, &g.BinaryID, &g.AgentID,
&denyArgs, &denyVerbose, &timeout, &tips,
&g.Enabled, &g.CreatedAt, &g.UpdatedAt,
); err != nil {
continue
}
s.applyNullable(&g, denyArgs, denyVerbose, timeout, tips)
result = append(result, g)
}
return result, nil
}
// applyNullable converts scanned nullable values to pointer fields on the grant struct.
func (s *PGSecureCLIAgentGrantStore) applyNullable(g *store.SecureCLIAgentGrant, denyArgs, denyVerbose *[]byte, timeout *int, tips *string) {
if denyArgs != nil {
raw := json.RawMessage(*denyArgs)
g.DenyArgs = &raw
}
if denyVerbose != nil {
raw := json.RawMessage(*denyVerbose)
g.DenyVerbose = &raw
}
g.TimeoutSeconds = timeout
g.Tips = tips
}
// nullableJSON returns nil if the pointer is nil, otherwise the raw bytes for the DB driver.
func nullableJSON(v *json.RawMessage) any {
if v == nil {
return nil
}
return []byte(*v)
}
+53 -6
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
@@ -19,7 +20,7 @@ type SecureCLIBinary struct {
DenyVerbose json.RawMessage `json:"deny_verbose"` // blocked verbose/debug flags
TimeoutSeconds int `json:"timeout_seconds"`
Tips string `json:"tips"` // hint injected into TOOLS.md context
AgentID *uuid.UUID `json:"agent_id,omitempty"`
IsGlobal bool `json:"is_global"`
Enabled bool `json:"enabled"`
CreatedBy string `json:"created_by"`
UserEnv []byte `json:"-"` // per-user encrypted env (populated by LookupByBinary LEFT JOIN)
@@ -27,6 +28,26 @@ type SecureCLIBinary struct {
EnvKeys []string `json:"env_keys,omitempty"`
}
// MergeGrantOverrides applies agent grant overrides onto a binary config.
// Non-nil grant fields replace binary defaults; nil fields keep binary values.
func (b *SecureCLIBinary) MergeGrantOverrides(g *SecureCLIAgentGrant) {
if g == nil {
return
}
if g.DenyArgs != nil {
b.DenyArgs = *g.DenyArgs
}
if g.DenyVerbose != nil {
b.DenyVerbose = *g.DenyVerbose
}
if g.TimeoutSeconds != nil {
b.TimeoutSeconds = *g.TimeoutSeconds
}
if g.Tips != nil {
b.Tips = *g.Tips
}
}
// SecureCLIUserCredential holds per-user encrypted env overrides for a binary.
type SecureCLIUserCredential struct {
ID uuid.UUID `json:"id"`
@@ -39,6 +60,20 @@ type SecureCLIUserCredential struct {
EncryptedEnv []byte `json:"-"`
}
// SecureCLIAgentGrant represents a per-agent grant with optional setting overrides.
type SecureCLIAgentGrant struct {
BaseModel
BinaryID uuid.UUID `json:"binary_id"`
AgentID uuid.UUID `json:"agent_id"`
DenyArgs *json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
Tips *string `json:"tips,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SecureCLIStore manages secure CLI binary credential configurations.
type SecureCLIStore interface {
Create(ctx context.Context, b *SecureCLIBinary) error
@@ -46,17 +81,19 @@ type SecureCLIStore interface {
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context) ([]SecureCLIBinary, error)
ListByAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIBinary, error)
// LookupByBinary finds the best-matching credential config for a binary name.
// Priority: agent-specific > global (agent_id IS NULL). Returns nil if not found.
// If userID is non-empty, also fetches per-user env overrides via LEFT JOIN
// and populates SecureCLIBinary.UserEnv (zero extra queries).
// LookupByBinary finds the credential config for a binary name.
// If agentID is provided, checks grant authorization and merges overrides.
// If userID is non-empty, also fetches per-user env overrides via LEFT JOIN.
LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*SecureCLIBinary, error)
// ListEnabled returns all enabled configs (for TOOLS.md context generation).
ListEnabled(ctx context.Context) ([]SecureCLIBinary, error)
// ListForAgent returns all CLIs accessible by an agent (global + granted),
// with grant overrides merged into the returned configs.
ListForAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIBinary, error)
// --- Per-user credential management ---
GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*SecureCLIUserCredential, error)
@@ -64,3 +101,13 @@ type SecureCLIStore interface {
DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error
ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error)
}
// SecureCLIAgentGrantStore manages per-agent grants for secure CLI binaries.
type SecureCLIAgentGrantStore interface {
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
Delete(ctx context.Context, id uuid.UUID) error
ListByBinary(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIAgentGrant, error)
ListByAgent(ctx context.Context, agentID uuid.UUID) ([]SecureCLIAgentGrant, error)
}
+23 -1
View File
@@ -14,7 +14,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 5
const SchemaVersion = 6
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -78,6 +78,28 @@ CREATE UNIQUE INDEX idx_channel_contacts_tenant_type_sender
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_parent_status ON subagent_tasks(tenant_id, parent_agent_key, status);
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_session ON subagent_tasks(session_key);
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_created ON subagent_tasks(tenant_id, created_at);`,
// Version 5 → 6: secure CLI agent grants — replace agent_id with is_global + grants table.
5: `ALTER TABLE secure_cli_binaries ADD COLUMN is_global BOOLEAN NOT NULL DEFAULT 1;
DROP INDEX IF EXISTS idx_secure_cli_unique_binary_agent;
DROP INDEX IF EXISTS idx_secure_cli_agent_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_tenant ON secure_cli_binaries(binary_name, tenant_id);
CREATE TABLE IF NOT EXISTS secure_cli_agent_grants (
id TEXT NOT NULL PRIMARY KEY,
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
deny_args TEXT,
deny_verbose TEXT,
timeout_seconds INTEGER,
tips TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(binary_id, agent_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_scag_binary ON secure_cli_agent_grants(binary_id);
CREATE INDEX IF NOT EXISTS idx_scag_agent ON secure_cli_agent_grants(agent_id);
CREATE INDEX IF NOT EXISTS idx_scag_tenant ON secure_cli_agent_grants(tenant_id);`,
}
// EnsureSchema creates tables if they don't exist and applies incremental migrations.
+25 -4
View File
@@ -1175,7 +1175,7 @@ CREATE TABLE IF NOT EXISTS secure_cli_binaries (
deny_verbose TEXT NOT NULL DEFAULT '[]',
timeout_seconds INTEGER NOT NULL DEFAULT 30,
tips TEXT NOT NULL DEFAULT '',
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
is_global BOOLEAN NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_by TEXT NOT NULL DEFAULT '',
tenant_id TEXT NOT NULL REFERENCES tenants(id),
@@ -1184,11 +1184,32 @@ CREATE TABLE IF NOT EXISTS secure_cli_binaries (
);
CREATE INDEX IF NOT EXISTS idx_secure_cli_binary_name ON secure_cli_binaries(binary_name);
CREATE INDEX IF NOT EXISTS idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL;
-- COALESCE(agent_id, '') simulates the PG COALESCE(agent_id, null-sentinel) unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_agent ON secure_cli_binaries(binary_name, COALESCE(agent_id, ''));
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_tenant ON secure_cli_binaries(binary_name, tenant_id);
CREATE INDEX IF NOT EXISTS idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id);
-- ============================================================
-- Table: secure_cli_agent_grants
-- ============================================================
CREATE TABLE IF NOT EXISTS secure_cli_agent_grants (
id TEXT NOT NULL PRIMARY KEY,
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
deny_args TEXT,
deny_verbose TEXT,
timeout_seconds INTEGER,
tips TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(binary_id, agent_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_scag_binary ON secure_cli_agent_grants(binary_id);
CREATE INDEX IF NOT EXISTS idx_scag_agent ON secure_cli_agent_grants(agent_id);
CREATE INDEX IF NOT EXISTS idx_scag_tenant ON secure_cli_agent_grants(tenant_id);
-- ============================================================
-- Table: api_keys
-- ============================================================
+3 -2
View File
@@ -24,8 +24,9 @@ type Stores struct {
Contacts ContactStore
Activity ActivityStore
Snapshots SnapshotStore
SecureCLI SecureCLIStore
APIKeys APIKeyStore
SecureCLI SecureCLIStore
SecureCLIGrants SecureCLIAgentGrantStore
APIKeys APIKeyStore
Heartbeats HeartbeatStore
ConfigPermissions ConfigPermissionStore
Tenants TenantStore
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 35
const RequiredSchemaVersion uint = 36
@@ -0,0 +1,10 @@
-- Reverse: add agent_id back, drop is_global, drop grants table
ALTER TABLE secure_cli_binaries ADD COLUMN agent_id UUID REFERENCES agents(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS idx_secure_cli_unique_binary_tenant;
CREATE UNIQUE INDEX idx_secure_cli_unique_binary_agent
ON secure_cli_binaries(binary_name, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid));
CREATE INDEX idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL;
ALTER TABLE secure_cli_binaries DROP COLUMN IF EXISTS is_global;
DROP TABLE IF EXISTS secure_cli_agent_grants;
@@ -0,0 +1,95 @@
-- Per-agent grants for secure CLI binaries with optional setting overrides.
-- Separates "which agents can use a binary" from "binary credential definition".
-- 1. Create agent grants table
CREATE TABLE secure_cli_agent_grants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
deny_args JSONB, -- NULL = use binary default
deny_verbose JSONB, -- NULL = use binary default
timeout_seconds INTEGER, -- NULL = use binary default
tips TEXT, -- NULL = use binary default
enabled BOOLEAN NOT NULL DEFAULT true,
tenant_id UUID NOT NULL REFERENCES tenants(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(binary_id, agent_id, tenant_id)
);
CREATE INDEX idx_scag_binary ON secure_cli_agent_grants(binary_id);
CREATE INDEX idx_scag_agent ON secure_cli_agent_grants(agent_id);
CREATE INDEX idx_scag_tenant ON secure_cli_agent_grants(tenant_id);
-- 2. Add is_global column (default true for backward compat)
ALTER TABLE secure_cli_binaries ADD COLUMN is_global BOOLEAN NOT NULL DEFAULT true;
-- 3. Migrate agent-specific rows to grants table.
-- Copy settings from agent-specific binaries into grants.
INSERT INTO secure_cli_agent_grants (binary_id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, tenant_id)
SELECT id, agent_id, deny_args, deny_verbose, timeout_seconds, tips, enabled, tenant_id
FROM secure_cli_binaries
WHERE agent_id IS NOT NULL;
-- 4. For agent-specific rows that HAVE a matching global row (same binary_name + tenant):
-- Re-point grants to the global row, then delete the now-orphaned agent-specific row.
UPDATE secure_cli_agent_grants g
SET binary_id = sub.global_id
FROM (
SELECT g2.id AS grant_id, b_global.id AS global_id
FROM secure_cli_agent_grants g2
JOIN secure_cli_binaries b_agent ON b_agent.id = g2.binary_id AND b_agent.agent_id IS NOT NULL
JOIN secure_cli_binaries b_global ON b_global.binary_name = b_agent.binary_name
AND b_global.tenant_id = b_agent.tenant_id
AND b_global.agent_id IS NULL
) sub
WHERE g.id = sub.grant_id;
DELETE FROM secure_cli_binaries
WHERE agent_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM secure_cli_binaries b2
WHERE b2.binary_name = secure_cli_binaries.binary_name
AND b2.tenant_id = secure_cli_binaries.tenant_id
AND b2.agent_id IS NULL
);
-- 5. For agent-specific rows WITHOUT a global counterpart:
-- Dedup: keep the row with the smallest id per (binary_name, tenant_id) as the
-- canonical binary definition. Re-point grants from duplicates to the keeper, then delete dupes.
-- 5a. Re-point grants from duplicate rows to the canonical (MIN id) row.
UPDATE secure_cli_agent_grants g
SET binary_id = keeper.keeper_id
FROM (
SELECT b.id AS dup_id, first_value(b.id) OVER (
PARTITION BY b.binary_name, b.tenant_id ORDER BY b.id
) AS keeper_id
FROM secure_cli_binaries b
WHERE b.agent_id IS NOT NULL
) keeper
WHERE g.binary_id = keeper.dup_id
AND keeper.dup_id != keeper.keeper_id;
-- 5b. Delete duplicate rows (keep only the canonical per binary_name+tenant_id).
DELETE FROM secure_cli_binaries
WHERE agent_id IS NOT NULL
AND id NOT IN (
SELECT DISTINCT ON (binary_name, tenant_id) id
FROM secure_cli_binaries
WHERE agent_id IS NOT NULL
ORDER BY binary_name, tenant_id, id
);
-- 5c. Mark remaining agent-specific rows as restricted (is_global = false).
UPDATE secure_cli_binaries SET is_global = false
WHERE agent_id IS NOT NULL;
-- 6. Drop agent_id column and old indexes.
DROP INDEX IF EXISTS idx_secure_cli_unique_binary_agent;
DROP INDEX IF EXISTS idx_secure_cli_agent_id;
ALTER TABLE secure_cli_binaries DROP COLUMN agent_id;
-- 7. New unique constraint: one binary per name per tenant.
CREATE UNIQUE INDEX idx_secure_cli_unique_binary_tenant
ON secure_cli_binaries(binary_name, tenant_id);
@@ -7,7 +7,8 @@
"columns": {
"binary": "Binary",
"scope": "Scope",
"timeout": "Timeout"
"timeout": "Timeout",
"restricted": "Restricted"
},
"delete": {
"title": "Delete CLI Credential",
@@ -43,7 +44,9 @@
"checkBinary": "Check",
"binaryFound": "Found at {{path}}",
"binaryNotFound": "Binary not found in server PATH",
"checking": "Checking..."
"checking": "Checking...",
"isGlobal": "Available to all agents",
"isGlobalHint": "When off, only agents with explicit grants can use this CLI"
},
"placeholders": {
"binaryName": "e.g. gh",
@@ -83,5 +86,29 @@
"deleteFailed": "Failed to delete user credentials",
"envRequired": "At least one environment variable is required",
"mergeHint": "Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page before they can have per-user credentials."
},
"grants": {
"title": "Agent Grants — {{name}}",
"currentGrants": "Current Grants",
"addGrant": "Add Grant",
"editGrant": "Edit Grant",
"selectAgent": "Select agent...",
"usingDefaults": "Using binary defaults",
"overrideTimeout": "Override timeout (seconds)",
"overrideDenyArgs": "Override deny args",
"overrideDenyVerbose": "Override deny verbose",
"overrideTips": "Override tips",
"defaultPlaceholder": "default",
"grant": "Grant",
"update": "Update",
"agentRequired": "Please select an agent",
"toast": {
"granted": "Agent grant created",
"grantFailed": "Failed to create grant",
"updated": "Agent grant updated",
"updateFailed": "Failed to update grant",
"revoked": "Agent grant revoked",
"revokeFailed": "Failed to revoke grant"
}
}
}
@@ -7,7 +7,8 @@
"columns": {
"binary": "Binary",
"scope": "Phạm vi",
"timeout": "Thời gian chờ"
"timeout": "Thời gian chờ",
"restricted": "Giới hạn"
},
"delete": {
"title": "Xóa thông tin CLI",
@@ -43,7 +44,9 @@
"checkBinary": "Kiểm tra",
"binaryFound": "Tìm thấy tại {{path}}",
"binaryNotFound": "Không tìm thấy binary trên server",
"checking": "Đang kiểm tra..."
"checking": "Đang kiểm tra...",
"isGlobal": "Khả dụng cho tất cả agent",
"isGlobalHint": "Khi tắt, chỉ agent có grant mới dùng được CLI này"
},
"placeholders": {
"binaryName": "vd: gh",
@@ -83,5 +86,29 @@
"deleteFailed": "Không thể xóa thông tin người dùng",
"envRequired": "Cần ít nhất một biến môi trường",
"mergeHint": "Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước khi có thể thiết lập credentials riêng."
},
"grants": {
"title": "Phân quyền Agent — {{name}}",
"currentGrants": "Quyền hiện tại",
"addGrant": "Thêm quyền",
"editGrant": "Sửa quyền",
"selectAgent": "Chọn agent...",
"usingDefaults": "Sử dụng cấu hình mặc định",
"overrideTimeout": "Ghi đè timeout (giây)",
"overrideDenyArgs": "Ghi đè deny args",
"overrideDenyVerbose": "Ghi đè deny verbose",
"overrideTips": "Ghi đè tips",
"defaultPlaceholder": "mặc định",
"grant": "Cấp quyền",
"update": "Cập nhật",
"agentRequired": "Vui lòng chọn agent",
"toast": {
"granted": "Đã cấp quyền agent",
"grantFailed": "Cấp quyền thất bại",
"updated": "Đã cập nhật quyền",
"updateFailed": "Cập nhật quyền thất bại",
"revoked": "Đã thu hồi quyền",
"revokeFailed": "Thu hồi quyền thất bại"
}
}
}
@@ -7,7 +7,8 @@
"columns": {
"binary": "二进制文件",
"scope": "范围",
"timeout": "超时"
"timeout": "超时",
"restricted": "受限"
},
"delete": {
"title": "删除 CLI 凭证",
@@ -43,7 +44,9 @@
"checkBinary": "检查",
"binaryFound": "已找到:{{path}}",
"binaryNotFound": "在服务器 PATH 中未找到",
"checking": "检查中..."
"checking": "检查中...",
"isGlobal": "对所有代理可用",
"isGlobalHint": "关闭后,只有明确授权的代理才能使用此CLI"
},
"placeholders": {
"binaryName": "例如 gh",
@@ -83,5 +86,29 @@
"deleteFailed": "删除用户凭证失败",
"envRequired": "至少需要一个环境变量",
"mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。"
},
"grants": {
"title": "代理授权 — {{name}}",
"currentGrants": "当前授权",
"addGrant": "添加授权",
"editGrant": "编辑授权",
"selectAgent": "选择代理...",
"usingDefaults": "使用默认配置",
"overrideTimeout": "覆盖超时(秒)",
"overrideDenyArgs": "覆盖拒绝参数",
"overrideDenyVerbose": "覆盖拒绝详细参数",
"overrideTips": "覆盖提示",
"defaultPlaceholder": "默认",
"grant": "授权",
"update": "更新",
"agentRequired": "请选择代理",
"toast": {
"granted": "代理授权已创建",
"grantFailed": "创建授权失败",
"updated": "代理授权已更新",
"updateFailed": "更新授权失败",
"revoked": "代理授权已撤销",
"revokeFailed": "撤销授权失败"
}
}
}
@@ -12,7 +12,6 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useHttp } from "@/hooks/use-ws";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials";
import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section";
import { CliCredentialBinaryFields } from "./cli-credential-binary-fields";
@@ -34,7 +33,6 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const http = useHttp();
const { agents } = useAgents();
const [selectedPreset, setSelectedPreset] = useState(NONE_PRESET);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
@@ -63,7 +61,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
denyVerbose: "",
timeout: 30,
tips: "",
agentId: "",
isGlobal: true,
enabled: true,
},
});
@@ -80,7 +78,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
denyVerbose: (credential?.deny_verbose ?? []).join(", "),
timeout: credential?.timeout_seconds ?? 30,
tips: credential?.tips ?? "",
agentId: credential?.agent_id ?? "",
isGlobal: credential?.is_global ?? true,
enabled: credential?.enabled ?? true,
});
setEnvValues({});
@@ -129,7 +127,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
denyVerbose: p.deny_verbose.join(", "),
timeout: p.timeout,
tips: p.tips,
agentId: "",
isGlobal: true,
enabled: true,
});
setEnvValues({});
@@ -184,7 +182,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
deny_verbose: splitCommaList(values.denyVerbose ?? ""),
timeout_seconds: values.timeout,
tips: values.tips?.trim() ?? "",
agent_id: values.agentId?.trim() || undefined,
is_global: values.isGlobal,
enabled: values.enabled,
};
if (selectedPreset !== NONE_PRESET) payload.preset = selectedPreset;
@@ -254,7 +252,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
onCheckBinary={handleCheckBinary}
/>
<CliCredentialScopeFields form={form} agents={agents} />
<CliCredentialScopeFields form={form} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import { Trash2, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { CLIAgentGrant } from "./hooks/use-cli-credentials";
interface Props {
grant: CLIAgentGrant;
agentName: string;
isActive: boolean;
disabled: boolean;
onSelect: () => void;
onRevoke: () => void;
}
/** Renders a single agent grant card in the grants list. */
export function CliCredentialGrantCard({ grant, agentName, isActive, disabled, onSelect, onRevoke }: Props) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const hasOverrides = grant.deny_args || grant.deny_verbose ||
grant.timeout_seconds != null || grant.tips;
return (
<div
className={cn(
"rounded-md border px-3 py-2.5 cursor-pointer transition-colors",
isActive ? "border-ring bg-accent/50 ring-1 ring-ring/30" : "bg-muted/30 hover:bg-muted/50",
)}
onClick={onSelect}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{agentName}</span>
{!grant.enabled && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{tc("disabled")}</Badge>
)}
{isActive && <Pencil className="h-3 w-3 text-muted-foreground" />}
</div>
{hasOverrides ? (
<div className="mt-1 flex flex-wrap gap-1">
{grant.timeout_seconds != null && (
<Badge variant="outline" className="font-mono text-[10px] px-1.5 py-0">
timeout: {grant.timeout_seconds}s
</Badge>
)}
{grant.deny_args && (
<Badge variant="outline" className="font-mono text-[10px] px-1.5 py-0">
deny: {grant.deny_args.length} rules
</Badge>
)}
{grant.tips && (
<Badge variant="outline" className="font-mono text-[10px] px-1.5 py-0 max-w-[200px] truncate">
tips: {grant.tips}
</Badge>
)}
</div>
) : (
<p className="text-xs text-muted-foreground mt-0.5">{t("grants.usingDefaults")}</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={(e) => { e.stopPropagation(); onRevoke(); }}
disabled={disabled}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { useTranslation } from "react-i18next";
import { Plus, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import type { AgentData } from "@/types/agent";
import type { SecureCLIBinary } from "./hooks/use-cli-credentials";
interface Props {
binary: SecureCLIBinary;
agents: AgentData[];
agentId: string;
setAgentId: (v: string) => void;
denyArgs: string;
setDenyArgs: (v: string) => void;
denyVerbose: string;
setDenyVerbose: (v: string) => void;
timeout: string;
setTimeout: (v: string) => void;
tips: string;
setTips: (v: string) => void;
enabled: boolean;
setEnabled: (v: boolean) => void;
isEditing: boolean;
saving: boolean;
onSubmit: () => void;
onCancel: () => void;
}
/** Inline form for adding or editing a per-agent grant. */
export function CliCredentialGrantForm({
binary, agents, agentId, setAgentId,
denyArgs, setDenyArgs, denyVerbose, setDenyVerbose,
timeout, setTimeout, tips, setTips,
enabled, setEnabled, isEditing, saving,
onSubmit, onCancel,
}: Props) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
return (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{isEditing ? t("grants.editGrant") : t("grants.addGrant")}
</Label>
{isEditing && (
<Button variant="ghost" size="sm" onClick={onCancel} className="h-6 px-2 text-xs text-muted-foreground">
{tc("cancel")}
</Button>
)}
</div>
<div className="grid gap-3">
<Select value={agentId} onValueChange={setAgentId} disabled={isEditing}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("grants.selectAgent")} />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
<span>{a.display_name || a.agent_key}</span>
<span className="ml-2 text-xs text-muted-foreground">{a.agent_key}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("grants.overrideDenyArgs")}</Label>
<Input
value={denyArgs}
onChange={(e) => setDenyArgs(e.target.value)}
placeholder={binary.deny_args?.join(", ") || t("grants.defaultPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("grants.overrideDenyVerbose")}</Label>
<Input
value={denyVerbose}
onChange={(e) => setDenyVerbose(e.target.value)}
placeholder={binary.deny_verbose?.join(", ") || t("grants.defaultPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("grants.overrideTimeout")}</Label>
<Input
type="number"
min={1}
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
placeholder={`${binary.timeout_seconds} (${t("grants.defaultPlaceholder")})`}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground">{t("grants.overrideTips")}</Label>
<Textarea
value={tips}
onChange={(e) => setTips(e.target.value)}
placeholder={binary.tips || t("grants.defaultPlaceholder")}
rows={2}
className="text-base md:text-sm resize-none"
/>
</div>
<div className="flex items-center gap-2">
<Switch id="grant-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="grant-enabled">{tc("enabled")}</Label>
</div>
</div>
<Button size="sm" onClick={onSubmit} disabled={saving || !agentId} className="gap-1">
{isEditing ? (
<><Pencil className="h-3.5 w-3.5" /> {t("grants.update")}</>
) : (
<><Plus className="h-3.5 w-3.5" /> {t("grants.grant")}</>
)}
</Button>
</div>
);
}
@@ -0,0 +1,165 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useCliCredentialGrants } from "./hooks/use-cli-credentials";
import { CliCredentialGrantCard } from "./cli-credential-grant-card";
import { CliCredentialGrantForm } from "./cli-credential-grant-form";
import type { SecureCLIBinary, CLIAgentGrant } from "./hooks/use-cli-credentials";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
binary: SecureCLIBinary;
}
/** Dialog for managing per-agent grants on a CLI credential. */
export function CliCredentialGrantsDialog({ open, onOpenChange, binary }: Props) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const { agents } = useAgents();
const { grants, loading, createGrant, updateGrant, deleteGrant } = useCliCredentialGrants(binary.id);
const [agentId, setAgentId] = useState("");
const [denyArgs, setDenyArgs] = useState("");
const [denyVerbose, setDenyVerbose] = useState("");
const [timeout, setTimeout] = useState("");
const [tips, setTips] = useState("");
const [enabled, setEnabled] = useState(true);
const [editingGrant, setEditingGrant] = useState<CLIAgentGrant | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const agentNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const a of agents) map.set(a.id, a.display_name || a.agent_key);
return map;
}, [agents]);
useEffect(() => {
if (open) clearForm();
}, [open]);
const clearForm = () => {
setAgentId("");
setDenyArgs("");
setDenyVerbose("");
setTimeout("");
setTips("");
setEnabled(true);
setEditingGrant(null);
setError("");
};
const selectGrant = (grant: CLIAgentGrant) => {
setAgentId(grant.agent_id);
setDenyArgs(grant.deny_args?.join(", ") ?? "");
setDenyVerbose(grant.deny_verbose?.join(", ") ?? "");
setTimeout(grant.timeout_seconds != null ? String(grant.timeout_seconds) : "");
setTips(grant.tips ?? "");
setEnabled(grant.enabled);
setEditingGrant(grant);
setError("");
};
const splitComma = (v: string): string[] | null => {
const items = v.split(",").map((s) => s.trim()).filter(Boolean);
return items.length > 0 ? items : null;
};
const handleSubmit = async () => {
if (!agentId) { setError(t("grants.agentRequired")); return; }
setSaving(true);
setError("");
try {
const input = {
agent_id: agentId,
deny_args: splitComma(denyArgs),
deny_verbose: splitComma(denyVerbose),
timeout_seconds: timeout ? parseInt(timeout, 10) : null,
tips: tips.trim() || null,
enabled,
};
if (editingGrant) {
await updateGrant(editingGrant.id, input);
} else {
await createGrant(input);
}
clearForm();
} catch (err) {
setError(err instanceof Error ? err.message : tc("error"));
} finally {
setSaving(false);
}
};
const handleRevoke = async (grant: CLIAgentGrant) => {
setSaving(true);
try {
await deleteGrant(grant.id);
if (editingGrant?.id === grant.id) clearForm();
} catch (err) {
setError(err instanceof Error ? err.message : tc("error"));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] flex flex-col sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("grants.title", { name: binary.binary_name })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{grants.length > 0 && (
<div className="space-y-2">
<Label>{t("grants.currentGrants")}</Label>
<div className="grid gap-2">
{grants.map((grant) => (
<CliCredentialGrantCard
key={grant.id}
grant={grant}
agentName={agentNameMap.get(grant.agent_id) || grant.agent_id}
isActive={editingGrant?.id === grant.id}
disabled={saving}
onSelect={() => selectGrant(grant)}
onRevoke={() => handleRevoke(grant)}
/>
))}
</div>
</div>
)}
<CliCredentialGrantForm
binary={binary}
agents={agents}
agentId={agentId}
setAgentId={setAgentId}
denyArgs={denyArgs}
setDenyArgs={setDenyArgs}
denyVerbose={denyVerbose}
setDenyVerbose={setDenyVerbose}
timeout={timeout}
setTimeout={setTimeout}
tips={tips}
setTips={setTips}
enabled={enabled}
setEnabled={setEnabled}
isEditing={editingGrant !== null}
saving={saving}
onSubmit={handleSubmit}
onCancel={clearForm}
/>
{loading && <p className="text-xs text-muted-foreground">{tc("loading")}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,56 +3,30 @@ import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentData } from "@/types/agent";
import type { CliCredentialFormData } from "@/schemas/credential.schema";
const GLOBAL_AGENT = "__global__";
interface CliCredentialScopeFieldsProps {
form: UseFormReturn<CliCredentialFormData>;
agents: AgentData[];
}
/** Renders agent scope selector and enabled toggle for a CLI credential. */
export function CliCredentialScopeFields({ form, agents }: CliCredentialScopeFieldsProps) {
/** Renders is_global toggle and enabled switch for a CLI credential. */
export function CliCredentialScopeFields({ form }: CliCredentialScopeFieldsProps) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const { control } = form;
return (
<>
<div className="grid gap-1.5">
<Label>
{t("form.agentId")}{" "}
<span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span>
</Label>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
<Label htmlFor="cc-global">{t("form.isGlobal")}</Label>
<p className="text-xs text-muted-foreground">{t("form.isGlobalHint")}</p>
</div>
<Controller
control={control}
name="agentId"
name="isGlobal"
render={({ field }) => (
<Select
value={field.value || GLOBAL_AGENT}
onValueChange={(v) => field.onChange(v === GLOBAL_AGENT ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("placeholders.agentId")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_AGENT}>{t("placeholders.agentId")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Switch id="cc-global" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
</div>
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Plus, RefreshCw, Pencil, Trash2, Users } from "lucide-react";
import { KeyRound, Plus, RefreshCw, Pencil, Trash2, Users, Shield } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
@@ -12,6 +12,7 @@ import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useCliCredentials, useCliCredentialPresets } from "./hooks/use-cli-credentials";
import { CliCredentialFormDialog } from "./cli-credential-form-dialog";
import { CLIUserCredentialsDialog } from "./cli-user-credentials-dialog";
import { CliCredentialGrantsDialog } from "./cli-credential-grants-dialog";
import type { SecureCLIBinary, CLICredentialInput } from "./hooks/use-cli-credentials";
export function CliCredentialsPage() {
@@ -23,6 +24,7 @@ export function CliCredentialsPage() {
const [deleteTarget, setDeleteTarget] = useState<SecureCLIBinary | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [userCredsTarget, setUserCredsTarget] = useState<SecureCLIBinary | null>(null);
const [grantsTarget, setGrantsTarget] = useState<SecureCLIBinary | null>(null);
const { items, loading, refresh, createCredential, updateCredential, deleteCredential } =
useCliCredentials();
@@ -118,8 +120,8 @@ export function CliCredentialsPage() {
{item.description || "—"}
</td>
<td className="px-4 py-3">
<Badge variant={item.agent_id ? "secondary" : "outline"}>
{item.agent_id ? tc("agent") : tc("global")}
<Badge variant={item.is_global ? "outline" : "secondary"}>
{item.is_global ? tc("global") : t("columns.restricted")}
</Badge>
</td>
<td className="px-4 py-3">
@@ -130,6 +132,9 @@ export function CliCredentialsPage() {
<td className="px-4 py-3 text-muted-foreground">{item.timeout_seconds}s</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => setGrantsTarget(item)} title={t("grants.title", { name: item.binary_name })}>
<Shield className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setUserCredsTarget(item)} title={t("userCredentials.title")}>
<Users className="h-3.5 w-3.5" />
</Button>
@@ -185,6 +190,14 @@ export function CliCredentialsPage() {
binary={userCredsTarget}
/>
)}
{grantsTarget && (
<CliCredentialGrantsDialog
open={!!grantsTarget}
onOpenChange={(open) => !open && setGrantsTarget(null)}
binary={grantsTarget}
/>
)}
</div>
);
}
@@ -3,9 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { toast } from "@/stores/use-toast-store";
import i18n from "@/i18n";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "@/types/cli-credential";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput } from "@/types/cli-credential";
export type { SecureCLIBinary, CLICredentialInput, CLIPreset };
export type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput };
const QUERY_KEY = ["cliCredentials"] as const;
const PRESETS_KEY = ["cliCredentials", "presets"] as const;
@@ -93,3 +93,77 @@ export function useCliCredentialPresets() {
return { presets: data ?? {}, loading: isLoading };
}
/** Hook for managing per-agent grants on a specific CLI binary. */
export function useCliCredentialGrants(binaryId: string) {
const http = useHttp();
const queryClient = useQueryClient();
const queryKey = ["cliCredentials", binaryId, "grants"] as const;
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const res = await http.get<{ grants: CLIAgentGrant[] }>(
`/v1/cli-credentials/${binaryId}/agent-grants`,
);
return res.grants ?? [];
},
enabled: !!binaryId,
});
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey }),
[queryClient, queryKey],
);
const createGrant = useCallback(
async (input: CLIAgentGrantInput) => {
try {
const res = await http.post<CLIAgentGrant>(
`/v1/cli-credentials/${binaryId}/agent-grants`,
input,
);
await invalidate();
toast.success(i18n.t("cli-credentials:grants.toast.granted"));
return res;
} catch (err) {
toast.error(i18n.t("cli-credentials:grants.toast.grantFailed"), err instanceof Error ? err.message : "");
throw err;
}
},
[http, binaryId, invalidate],
);
const updateGrant = useCallback(
async (grantId: string, input: Partial<CLIAgentGrantInput>) => {
try {
await http.put(
`/v1/cli-credentials/${binaryId}/agent-grants/${grantId}`,
input,
);
await invalidate();
toast.success(i18n.t("cli-credentials:grants.toast.updated"));
} catch (err) {
toast.error(i18n.t("cli-credentials:grants.toast.updateFailed"), err instanceof Error ? err.message : "");
throw err;
}
},
[http, binaryId, invalidate],
);
const deleteGrant = useCallback(
async (grantId: string) => {
try {
await http.delete(`/v1/cli-credentials/${binaryId}/agent-grants/${grantId}`);
await invalidate();
toast.success(i18n.t("cli-credentials:grants.toast.revoked"));
} catch (err) {
toast.error(i18n.t("cli-credentials:grants.toast.revokeFailed"), err instanceof Error ? err.message : "");
throw err;
}
},
[http, binaryId, invalidate],
);
return { grants: data ?? [], loading: isLoading, createGrant, updateGrant, deleteGrant };
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const cliCredentialSchema = z.object({
denyVerbose: z.string().optional(),
timeout: z.number().min(1),
tips: z.string().optional(),
agentId: z.string().optional(),
isGlobal: z.boolean(),
enabled: z.boolean(),
});
+25 -2
View File
@@ -7,7 +7,7 @@ export interface SecureCLIBinary {
deny_verbose: string[];
timeout_seconds: number;
tips: string;
agent_id?: string;
is_global: boolean;
enabled: boolean;
created_by: string;
created_at: string;
@@ -42,7 +42,30 @@ export interface CLICredentialInput {
deny_verbose?: string[];
timeout_seconds?: number;
tips?: string;
agent_id?: string;
is_global?: boolean;
enabled?: boolean;
env?: Record<string, string>;
}
/** Per-agent grant with optional setting overrides */
export interface CLIAgentGrant {
id: string;
binary_id: string;
agent_id: string;
deny_args: string[] | null;
deny_verbose: string[] | null;
timeout_seconds: number | null;
tips: string | null;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CLIAgentGrantInput {
agent_id: string;
deny_args?: string[] | null;
deny_verbose?: string[] | null;
timeout_seconds?: number | null;
tips?: string | null;
enabled?: boolean;
}