mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
feat(cli-credentials): add agent-scoped git credentials
This commit is contained in:
1 parent
e6242dbbbc
commit
994acfe3a4
47 files changed
+2152
-113
No files matched your search
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 45
|
||||
const SchemaVersion = 46
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -807,6 +807,30 @@ CREATE INDEX IF NOT EXISTS idx_run_timeline_trace
|
||||
43: addChannelContextCapabilityTables,
|
||||
// Version 44 → 45: passive channel memory extraction run and review queue.
|
||||
44: addChannelMemoryExtractionTables,
|
||||
// Version 45 → 46: per-agent typed Secure CLI credentials.
|
||||
45: `CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant
|
||||
ON secure_cli_binaries(id, tenant_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant
|
||||
ON agents(id, tenant_id);
|
||||
CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
binary_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
encrypted_env BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
credential_type TEXT,
|
||||
host_scope TEXT,
|
||||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||||
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),
|
||||
FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`,
|
||||
}
|
||||
|
||||
const addChannelMemoryExtractionTables = `
|
||||
|
||||
@@ -1710,6 +1710,36 @@ CREATE TABLE IF NOT EXISTS secure_cli_user_credentials (
|
||||
CREATE INDEX IF NOT EXISTS idx_scuc_tenant ON secure_cli_user_credentials(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary_id);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: secure_cli_agent_credentials (per-agent encrypted env)
|
||||
-- ============================================================
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant
|
||||
ON secure_cli_binaries(id, tenant_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant
|
||||
ON agents(id, tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
binary_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
encrypted_env BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
credential_type TEXT,
|
||||
host_scope TEXT,
|
||||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||||
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),
|
||||
FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: vault_documents (V3 Knowledge Vault registry)
|
||||
-- ============================================================
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, COALESCE(c.metadata, '{}'),
|
||||
c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at`
|
||||
|
||||
func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials")
|
||||
}
|
||||
return tid, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = ? AND tenant_id = ?)`,
|
||||
binaryID, tid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM agents WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL)`,
|
||||
agentID, tid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c store.SecureCLIAgentCredential
|
||||
var env []byte
|
||||
var metaBytes []byte
|
||||
var createdAt, updatedAt string
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM secure_cli_agent_credentials c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id
|
||||
WHERE c.binary_id = ? AND c.agent_id = ? AND c.tenant_id = ?`,
|
||||
binaryID, agentID, tid,
|
||||
).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes,
|
||||
&c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt,
|
||||
&c.AgentKey, &c.Name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = createdAt
|
||||
c.UpdatedAt = updatedAt
|
||||
c.Metadata = metaBytes
|
||||
c.EncryptedEnv = s.decryptAgentCredentialEnv(env)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error {
|
||||
return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy)
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
id := store.GenNewID()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO secure_cli_agent_credentials
|
||||
(id, binary_id, agent_id, encrypted_env, metadata, tenant_id,
|
||||
credential_type, host_scope, created_by, created_at, updated_at)
|
||||
SELECT ?, b.id, a.id, ?, '{}', ?, ?, ?, ?, ?, ?
|
||||
FROM secure_cli_binaries b
|
||||
JOIN agents a ON a.id = ? AND a.tenant_id = ? AND a.deleted_at IS NULL
|
||||
WHERE b.id = ? AND b.tenant_id = ?
|
||||
ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET
|
||||
encrypted_env = excluded.encrypted_env,
|
||||
credential_type = excluded.credential_type,
|
||||
host_scope = excluded.host_scope,
|
||||
created_by = excluded.created_by,
|
||||
updated_at = excluded.updated_at`,
|
||||
id, envBytes, tid, credentialType, hostScope, createdBy, now, now,
|
||||
agentID, tid, binaryID, tid,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`DELETE FROM secure_cli_agent_credentials WHERE binary_id = ? AND agent_id = ? AND tenant_id = ?`,
|
||||
binaryID, agentID, tid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) {
|
||||
tid, err := agentCredentialTenantID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM secure_cli_agent_credentials c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id
|
||||
WHERE c.binary_id = ? AND c.tenant_id = ?
|
||||
ORDER BY c.created_at`, binaryID, tid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.SecureCLIAgentCredential
|
||||
for rows.Next() {
|
||||
var c store.SecureCLIAgentCredential
|
||||
var env []byte
|
||||
var metaBytes []byte
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes,
|
||||
&c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt,
|
||||
&c.AgentKey, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = createdAt
|
||||
c.UpdatedAt = updatedAt
|
||||
c.Metadata = metaBytes
|
||||
c.EncryptedEnv = s.decryptAgentCredentialEnv(env)
|
||||
result = append(result, c)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) {
|
||||
if len(env) == 0 || s.encKey == "" {
|
||||
return env, nil
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(string(env), s.encKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt agent credential env: %w", err)
|
||||
}
|
||||
return []byte(encrypted), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte {
|
||||
if len(env) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
|
||||
return []byte(decrypted)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -381,8 +381,6 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
|
||||
|
||||
var args []any
|
||||
|
||||
query := `SELECT ` + selectCols
|
||||
|
||||
// Project user-credential columns (encrypted_env + credential_type + host_scope).
|
||||
// When userID is empty we cannot reference uc_user.* — emit NULL placeholders
|
||||
// so the scan column count stays stable.
|
||||
@@ -390,10 +388,17 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
|
||||
hasUserJoinNoAgent := userID != "" && agentID == nil
|
||||
|
||||
if hasUserJoin || hasUserJoinNoAgent {
|
||||
query += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope FROM secure_cli_binaries b`
|
||||
selectCols += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope`
|
||||
} else {
|
||||
query += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope FROM secure_cli_binaries b`
|
||||
selectCols += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope`
|
||||
}
|
||||
if agentID != nil {
|
||||
selectCols += `, ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope`
|
||||
} else {
|
||||
selectCols += `, NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope`
|
||||
}
|
||||
|
||||
query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b`
|
||||
|
||||
// LEFT JOIN agent grant
|
||||
if agentID != nil {
|
||||
@@ -402,6 +407,14 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
|
||||
} else {
|
||||
query += ` LEFT JOIN secure_cli_agent_grants g ON 0`
|
||||
}
|
||||
if agentID != nil {
|
||||
query += ` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = ?`
|
||||
args = append(args, *agentID)
|
||||
if !isCross {
|
||||
query += ` AND ac.tenant_id = ?`
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
// LEFT JOIN user credentials (only when we project uc_user.*)
|
||||
if hasUserJoin || hasUserJoinNoAgent {
|
||||
@@ -457,6 +470,8 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
|
||||
var grantEncEnv []byte
|
||||
var userEnv []byte
|
||||
var userCredType, userHostScope *string
|
||||
var agentEnv []byte
|
||||
var agentCredType, agentHostScope *string
|
||||
var createdAt, updatedAt sqliteTime
|
||||
|
||||
err := row.Scan(
|
||||
@@ -466,6 +481,7 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
|
||||
&b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt,
|
||||
&grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv,
|
||||
&userEnv, &userCredType, &userHostScope,
|
||||
&agentEnv, &agentCredType, &agentHostScope,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -506,24 +522,46 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
|
||||
}
|
||||
grant.TimeoutSeconds = grantTimeout
|
||||
grant.Tips = grantTips
|
||||
if len(grantEncEnv) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil {
|
||||
grant.EncryptedEnv = []byte(decrypted)
|
||||
if len(grantEncEnv) > 0 {
|
||||
if s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil {
|
||||
grant.EncryptedEnv = []byte(decrypted)
|
||||
}
|
||||
} else {
|
||||
grant.EncryptedEnv = grantEncEnv
|
||||
}
|
||||
}
|
||||
b.MergeGrantOverrides(grant)
|
||||
}
|
||||
|
||||
// Decrypt per-user env
|
||||
if len(userEnv) > 0 && s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
|
||||
b.UserEnv = []byte(decrypted)
|
||||
if len(userEnv) > 0 {
|
||||
if s.encKey != "" {
|
||||
if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil {
|
||||
b.UserEnv = []byte(decrypted)
|
||||
}
|
||||
} else {
|
||||
b.UserEnv = userEnv
|
||||
}
|
||||
}
|
||||
|
||||
// Typed-credential metadata from the joined user-credential row.
|
||||
b.UserCredentialType = userCredType
|
||||
b.UserHostScope = userHostScope
|
||||
if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil {
|
||||
b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "")
|
||||
}
|
||||
if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil {
|
||||
decrypted := agentEnv
|
||||
if len(agentEnv) > 0 && s.encKey != "" {
|
||||
if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil {
|
||||
decrypted = []byte(d)
|
||||
}
|
||||
}
|
||||
if b.CredentialSource == "" {
|
||||
b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "")
|
||||
}
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
}
|
||||
@@ -533,7 +571,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto
|
||||
if len(scopes) == 0 || b == nil {
|
||||
return b, nil
|
||||
}
|
||||
hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
|
||||
hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
|
||||
disabledByContext := false
|
||||
for _, scope := range scopes {
|
||||
grants, err := s.ListContextGrantsForScope(ctx, scope)
|
||||
@@ -572,9 +610,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto
|
||||
continue
|
||||
}
|
||||
if !hasUserCredential {
|
||||
b.UserEnv = creds.EncryptedEnv
|
||||
b.UserCredentialType = creds.CredentialType
|
||||
b.UserHostScope = creds.HostScope
|
||||
b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey)
|
||||
} else {
|
||||
b.EncryptedEnv = creds.EncryptedEnv
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package sqlitestore
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -75,11 +76,11 @@ func TestSQLite_CreateBinary_AdapterNameRoundTrip(t *testing.T) {
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git with PAT adapter",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
BinaryName: "git",
|
||||
Description: "git with PAT adapter",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
@@ -206,11 +207,11 @@ func TestSQLite_LookupByBinary_ProjectsNewColumns(t *testing.T) {
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
@@ -268,3 +269,169 @@ func TestSQLite_LookupByBinary_BackwardCompatibleNulls(t *testing.T) {
|
||||
t.Fatalf("expected UserHostScope NULL, got %q", *got.UserHostScope)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID, key string) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO agents (id, tenant_id, agent_key, display_name, owner_id, provider, model, agent_type, status)
|
||||
VALUES (?, ?, ?, ?, 'owner', 'openai', 'gpt-5', 'predefined', 'active')`,
|
||||
id, tenantID, key, key,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent %s: %v", key, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSQLite_SetAgentCredentialsTyped_RoundTrip(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tid := seedTenant(t, db, "t-agent-typed")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
agentID := seedAgent(t, db, tid, "builder")
|
||||
|
||||
seedBinary(t, db, tid, "git", true, true)
|
||||
var binID uuid.UUID
|
||||
if err := db.QueryRow(`SELECT id FROM secure_cli_binaries WHERE binary_name = ? AND tenant_id = ?`, "git", tid).Scan(&binID); err != nil {
|
||||
t.Fatalf("lookup seeded binary: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
plaintext := []byte(`{"token":"ghp_agent"}`)
|
||||
if err := s.SetAgentCredentialsTyped(ctx, binID, agentID, plaintext, &credType, &hostScope, "admin"); err != nil {
|
||||
t.Fatalf("SetAgentCredentialsTyped: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAgentCredentials(ctx, binID, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgentCredentials: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected credential, got nil")
|
||||
}
|
||||
if got.CredentialType == nil || *got.CredentialType != "pat" {
|
||||
t.Fatalf("expected credential_type=pat, got %v", got.CredentialType)
|
||||
}
|
||||
if got.HostScope == nil || *got.HostScope != "github.com" {
|
||||
t.Fatalf("expected host_scope=github.com, got %v", got.HostScope)
|
||||
}
|
||||
if string(got.EncryptedEnv) != string(plaintext) {
|
||||
t.Fatalf("decrypted env mismatch: got %q want %q", got.EncryptedEnv, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_LookupByBinary_UsesAgentCredentialWithoutUserID(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tid := seedTenant(t, db, "t-agent-lookup")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
agentID := seedAgent(t, db, tid, "builder")
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
if err := s.Create(ctx, bin); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
if err := s.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil {
|
||||
t.Fatalf("SetAgentCredentialsTyped: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.LookupByBinary(ctx, "git", &agentID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupByBinary: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected binary, got nil")
|
||||
}
|
||||
if got.CredentialSource != "agent" {
|
||||
t.Fatalf("expected agent source, got %q", got.CredentialSource)
|
||||
}
|
||||
if got.CredentialType == nil || *got.CredentialType != "pat" {
|
||||
t.Fatalf("expected CredentialType=pat, got %v", got.CredentialType)
|
||||
}
|
||||
if string(got.CredentialEnv) != `{"token":"agent"}` {
|
||||
t.Fatalf("expected agent credential env, got %q", got.CredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_LookupByBinary_UserCredentialOverridesAgentCredential(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tid := seedTenant(t, db, "t-user-over-agent")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
agentID := seedAgent(t, db, tid, "builder")
|
||||
|
||||
adapter := "git"
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
AdapterName: &adapter,
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
if err := s.Create(ctx, bin); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
if err := s.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil {
|
||||
t.Fatalf("SetAgentCredentialsTyped: %v", err)
|
||||
}
|
||||
if err := s.SetUserCredentialsTyped(ctx, bin.ID, "u-1", []byte(`{"token":"user"}`), &credType, &hostScope); err != nil {
|
||||
t.Fatalf("SetUserCredentialsTyped: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.LookupByBinary(ctx, "git", &agentID, "u-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupByBinary: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected binary, got nil")
|
||||
}
|
||||
if got.CredentialSource != "user" {
|
||||
t.Fatalf("expected user source, got %q", got.CredentialSource)
|
||||
}
|
||||
if string(got.CredentialEnv) != `{"token":"user"}` {
|
||||
t.Fatalf("expected user credential env, got %q", got.CredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_SetAgentCredentialsRejectsCrossTenantAgent(t *testing.T) {
|
||||
s, db := newPhase1Store(t)
|
||||
tenantA := seedTenant(t, db, "t-agent-cred-a")
|
||||
tenantB := seedTenant(t, db, "t-agent-cred-b")
|
||||
ctxA := store.WithTenantID(context.Background(), tenantA)
|
||||
agentB := seedAgent(t, db, tenantB, "other-tenant-agent")
|
||||
|
||||
bin := &store.SecureCLIBinary{
|
||||
BinaryName: "git",
|
||||
Description: "git adapter binary",
|
||||
IsGlobal: true,
|
||||
Enabled: true,
|
||||
CreatedBy: "u-tester",
|
||||
EncryptedEnv: []byte(`{}`),
|
||||
}
|
||||
if err := s.Create(ctxA, bin); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
credType := "pat"
|
||||
hostScope := "github.com"
|
||||
err := s.SetAgentCredentialsTyped(ctxA, bin.ID, agentB, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows for cross-tenant agent, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user