mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 10:18:42 +00:00
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:
1 parent
29f06cc24b
commit
156b2dd96c
28 files changed
+1395
-188
No files matched your search
+4
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user