DB-backed repo_path_allowlist reader (deny-all until admin seeds), per-prefix admin test_commands (goose 00075), execution evidence table (goose 00074), isolated git-worktree patch executor with command allowlist/time/output caps and failure-stops evidence; controlled_patch creation/preview unlocked only with an allowlisted repo binding (projectworkflow gate + catalog flag); creator-only server endpoints for bindings/executions; merge/push/deploy stay behind a typed separate-authorization boundary. Module boundary: app->swboot composition import registered.
133 lines
3.4 KiB
Go
133 lines
3.4 KiB
Go
package swboot
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// allowlistEntry is one enabled admin-seeded row: a path prefix plus the
|
|
// test/build command prefixes registered for it (goose 00048 + 00075).
|
|
type allowlistEntry struct {
|
|
prefix string
|
|
testCommands []string
|
|
}
|
|
|
|
// PGRepoAllowlist is the runtime reader for the admin-seeded
|
|
// repo_path_allowlist table. Empty table (or empty prefixes list) denies
|
|
// everything — SWBOOT-10 stays fail-closed until an admin seeds enabled path
|
|
// prefixes.
|
|
type PGRepoAllowlist struct {
|
|
Pool *pgxpool.Pool
|
|
|
|
mu sync.RWMutex
|
|
cache map[string]allowlistEntry
|
|
cacheAt time.Time
|
|
cacheTTL time.Duration
|
|
}
|
|
|
|
// NewPGRepoAllowlist builds the DB-backed allowlist. ttl <= 0 disables caching.
|
|
func NewPGRepoAllowlist(pool *pgxpool.Pool, ttl time.Duration) *PGRepoAllowlist {
|
|
return &PGRepoAllowlist{Pool: pool, cacheTTL: ttl}
|
|
}
|
|
|
|
// IsAllowlisted reports whether absPath falls under an enabled prefix.
|
|
// With no pool it denies everything (no in-memory fallback in production).
|
|
func (a *PGRepoAllowlist) IsAllowlisted(absPath string) bool {
|
|
if a == nil || a.Pool == nil {
|
|
return false
|
|
}
|
|
clean := filepath.Clean(strings.TrimSpace(absPath))
|
|
if clean == "" || !filepath.IsAbs(clean) {
|
|
return false
|
|
}
|
|
m, err := a.entries(context.Background())
|
|
if err != nil || len(m) == 0 {
|
|
return false
|
|
}
|
|
for p := range m {
|
|
if clean == p || strings.HasPrefix(clean, p+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *PGRepoAllowlist) entries(ctx context.Context) (map[string]allowlistEntry, error) {
|
|
a.mu.RLock()
|
|
if a.cacheTTL > 0 && a.cache != nil && time.Since(a.cacheAt) < a.cacheTTL {
|
|
out := make(map[string]allowlistEntry, len(a.cache))
|
|
for k, v := range a.cache {
|
|
out[k] = v
|
|
}
|
|
a.mu.RUnlock()
|
|
return out, nil
|
|
}
|
|
a.mu.RUnlock()
|
|
if a.Pool == nil {
|
|
return nil, nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
rows, err := a.Pool.Query(ctx, `SELECT path_prefix, COALESCE(test_commands, '') FROM repo_path_allowlist WHERE enabled = true`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make(map[string]allowlistEntry, 4)
|
|
for rows.Next() {
|
|
var p, cmds string
|
|
if err := rows.Scan(&p, &cmds); err != nil {
|
|
return nil, err
|
|
}
|
|
p = filepath.Clean(strings.TrimSpace(p))
|
|
if p == "" {
|
|
continue
|
|
}
|
|
var cmdList []string
|
|
for _, c := range strings.Split(cmds, ",") {
|
|
c = strings.TrimSpace(c)
|
|
if c != "" {
|
|
cmdList = append(cmdList, c)
|
|
}
|
|
}
|
|
out[p] = allowlistEntry{prefix: p, testCommands: cmdList}
|
|
}
|
|
if a.cacheTTL > 0 {
|
|
a.mu.Lock()
|
|
a.cache, a.cacheAt = out, time.Now()
|
|
a.mu.Unlock()
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AllowedCommands returns the admin-registered test/build command prefixes
|
|
// covering absPath. Empty means no command may run (patch-only execution).
|
|
func (a *PGRepoAllowlist) AllowedCommands(absPath string) []string {
|
|
if a == nil || a.Pool == nil {
|
|
return nil
|
|
}
|
|
clean := filepath.Clean(strings.TrimSpace(absPath))
|
|
if clean == "" || !filepath.IsAbs(clean) {
|
|
return nil
|
|
}
|
|
m, err := a.entries(context.Background())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Longest covering prefix wins.
|
|
var best allowlistEntry
|
|
for _, e := range m {
|
|
if clean == e.prefix || strings.HasPrefix(clean, e.prefix+string(filepath.Separator)) {
|
|
if len(e.prefix) > len(best.prefix) {
|
|
best = e
|
|
}
|
|
}
|
|
}
|
|
return best.testCommands
|
|
}
|