Files

300 lines
7.9 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const permCacheTTL = 60 * time.Second
type permRow struct {
Scope string
ConfigType string
Permission string
UserID string
}
type permCacheEntry struct {
rows []permRow
fetched time.Time
}
type fwCacheEntry struct {
rows []store.ConfigPermission
fetched time.Time
}
// SQLiteConfigPermissionStore implements store.ConfigPermissionStore backed by SQLite.
// Includes a TTL cache for CheckPermission to avoid per-request DB queries.
type SQLiteConfigPermissionStore struct {
db *sql.DB
mu sync.RWMutex
cache map[string]permCacheEntry // key: "tenantID:agentID:userID"
fwMu sync.RWMutex
fwCache map[string]fwCacheEntry // key: "tenantID:agentID:scope"
}
func NewSQLiteConfigPermissionStore(db *sql.DB) *SQLiteConfigPermissionStore {
return &SQLiteConfigPermissionStore{
db: db,
cache: make(map[string]permCacheEntry),
fwCache: make(map[string]fwCacheEntry),
}
}
// InvalidateCache clears all cached permission entries.
func (s *SQLiteConfigPermissionStore) InvalidateCache() {
s.mu.Lock()
s.cache = make(map[string]permCacheEntry)
s.mu.Unlock()
s.fwMu.Lock()
s.fwCache = make(map[string]fwCacheEntry)
s.fwMu.Unlock()
}
func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
cacheKey := tid.String() + ":" + agentID.String() + ":" + userID
s.mu.RLock()
if entry, ok := s.cache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
s.mu.RUnlock()
return evalPermRows(entry.rows, scope, configType, userID), nil
}
s.mu.RUnlock()
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return false, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT scope, config_type, permission, user_id FROM agent_config_permissions
WHERE agent_id = ? AND (user_id = ? OR user_id = '*')`+tClause,
append([]any{agentID, userID}, tArgs...)...,
)
if err != nil {
return false, err
}
defer rows.Close()
var permRows []permRow
for rows.Next() {
var r permRow
if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission, &r.UserID); err != nil {
return false, err
}
permRows = append(permRows, r)
}
if err := rows.Err(); err != nil {
return false, err
}
s.mu.Lock()
s.cache[cacheKey] = permCacheEntry{rows: permRows, fetched: time.Now()}
s.mu.Unlock()
return evalPermRows(permRows, scope, configType, userID), nil
}
func (s *SQLiteConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigPermission) error {
meta := perm.Metadata
if meta == nil {
meta = json.RawMessage("{}")
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET
permission = excluded.permission,
granted_by = excluded.granted_by,
metadata = excluded.metadata,
updated_at = excluded.updated_at`,
perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, now, tenantIDForInsert(ctx),
)
if err == nil {
s.InvalidateCache()
}
return err
}
func (s *SQLiteConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`DELETE FROM agent_config_permissions WHERE agent_id = ? AND scope = ? AND config_type = ? AND user_id = ?`+tClause,
append([]any{agentID, scope, configType, userID}, tArgs...)...,
)
if err == nil {
s.InvalidateCache()
}
return err
}
func (s *SQLiteConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) {
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions WHERE agent_id = ?`
args := []any{agentID}
if configType != "" {
query += " AND config_type = ?"
args = append(args, configType)
}
if scope != "" {
query += " AND scope = ?"
args = append(args, scope)
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
if tClause != "" {
query += tClause
args = append(args, tArgs...)
}
query += " ORDER BY created_at"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanConfigPermissions(rows)
}
func (s *SQLiteConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
cacheKey := tid.String() + ":" + agentID.String() + ":" + scope
s.fwMu.RLock()
if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
s.fwMu.RUnlock()
return entry.rows, nil
}
s.fwMu.RUnlock()
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions
WHERE agent_id = ? AND config_type = 'file_writer' AND scope = ? AND permission = 'allow'`+tClause+`
ORDER BY created_at`,
append([]any{agentID, scope}, tArgs...)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
perms, err := scanConfigPermissions(rows)
if err != nil {
return nil, err
}
s.fwMu.Lock()
s.fwCache[cacheKey] = fwCacheEntry{rows: perms, fetched: time.Now()}
s.fwMu.Unlock()
return perms, nil
}
func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
var perms []store.ConfigPermission
for rows.Next() {
var p store.ConfigPermission
var metadata []byte
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&p.ID, &p.AgentID, &p.Scope, &p.ConfigType, &p.UserID, &p.Permission, &p.GrantedBy, &metadata, createdAt, updatedAt,
); err != nil {
return nil, err
}
p.CreatedAt = createdAt.Time
p.UpdatedAt = updatedAt.Time
p.Metadata = metadata
perms = append(perms, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return perms, nil
}
// evalPermRows evaluates cached permission rows with priority-based evaluation.
// Individual permissions (matching targetUserID) override group wildcards (user_id="*").
func evalPermRows(rows []permRow, scope, configType, targetUserID string) bool {
var individualDeny, individualAllow bool
var groupDeny, groupAllow bool
for _, r := range rows {
if !matchWildcard(r.Scope, scope) || !matchWildcard(r.ConfigType, configType) {
continue
}
if r.UserID == targetUserID {
switch r.Permission {
case "deny":
individualDeny = true
case "allow":
individualAllow = true
}
} else if r.UserID == "*" {
switch r.Permission {
case "deny":
groupDeny = true
case "allow":
groupAllow = true
}
}
}
if individualDeny {
return false
}
if individualAllow {
return true
}
if groupDeny {
return false
}
return groupAllow
}
// matchWildcard performs simple wildcard matching for scope/config_type.
func matchWildcard(pattern, value string) bool {
if pattern == "*" {
return true
}
if strings.HasSuffix(pattern, ":*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(value, prefix) || value == strings.TrimSuffix(prefix, ":")
}
return pattern == value
}
// ensure interface is satisfied at compile time
var _ store.ConfigPermissionStore = (*SQLiteConfigPermissionStore)(nil)