63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
4.4 KiB
Go
187 lines
4.4 KiB
Go
// Package variables provides dynamic variable expansion for rules
|
|
package variables
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Context holds data for variable expansion
|
|
type Context struct {
|
|
Object map[string]string
|
|
Rule map[string]string
|
|
Now time.Time
|
|
Custom map[string]string
|
|
}
|
|
|
|
// NewContext creates a new variable expansion context
|
|
func NewContext() *Context {
|
|
return &Context{
|
|
Object: make(map[string]string),
|
|
Rule: make(map[string]string),
|
|
Now: time.Now().UTC(),
|
|
Custom: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// WithObject sets object attributes
|
|
func (c *Context) WithObject(attrs map[string][]string) *Context {
|
|
for k, v := range attrs {
|
|
if len(v) > 0 {
|
|
c.Object[k] = v[0]
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// WithRule sets rule metadata
|
|
func (c *Context) WithRule(name, id string) *Context {
|
|
c.Rule["name"] = name
|
|
c.Rule["id"] = id
|
|
return c
|
|
}
|
|
|
|
// Expander handles variable expansion
|
|
type Expander struct {
|
|
variablePattern *regexp.Regexp
|
|
}
|
|
|
|
// NewExpander creates a new variable expander
|
|
func NewExpander() *Expander {
|
|
// Match {{source.property}} patterns
|
|
return &Expander{
|
|
variablePattern: regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\}`),
|
|
}
|
|
}
|
|
|
|
// Expand expands all variables in a template string
|
|
func (e *Expander) Expand(template string, ctx *Context) (string, error) {
|
|
var errors []string
|
|
|
|
result := e.variablePattern.ReplaceAllStringFunc(template, func(match string) string {
|
|
// Extract variable path (remove {{ and }})
|
|
path := match[2 : len(match)-2]
|
|
|
|
value, err := e.resolve(path, ctx)
|
|
if err != nil {
|
|
errors = append(errors, err.Error())
|
|
return match // Keep original on error
|
|
}
|
|
return value
|
|
})
|
|
|
|
if len(errors) > 0 {
|
|
return result, fmt.Errorf("variable expansion errors: %s", strings.Join(errors, "; "))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ExpandStrict expands variables and fails on any unresolved variable
|
|
func (e *Expander) ExpandStrict(template string, ctx *Context) (string, error) {
|
|
result, err := e.Expand(template, ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Check for any remaining unexpanded variables
|
|
remaining := e.variablePattern.FindAllString(result, -1)
|
|
if len(remaining) > 0 {
|
|
return "", fmt.Errorf("unresolved variables: %s", strings.Join(remaining, ", "))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *Expander) resolve(path string, ctx *Context) (string, error) {
|
|
parts := strings.SplitN(path, ".", 2)
|
|
if len(parts) != 2 {
|
|
return "", fmt.Errorf("invalid variable path: %s", path)
|
|
}
|
|
|
|
source := parts[0]
|
|
property := parts[1]
|
|
|
|
switch source {
|
|
case "object":
|
|
if val, ok := ctx.Object[property]; ok {
|
|
return val, nil
|
|
}
|
|
return "", fmt.Errorf("object property not found: %s", property)
|
|
|
|
case "rule":
|
|
if val, ok := ctx.Rule[property]; ok {
|
|
return val, nil
|
|
}
|
|
return "", fmt.Errorf("rule property not found: %s", property)
|
|
|
|
case "now":
|
|
return e.resolveNow(property, ctx.Now)
|
|
|
|
case "custom":
|
|
if val, ok := ctx.Custom[property]; ok {
|
|
return val, nil
|
|
}
|
|
return "", fmt.Errorf("custom property not found: %s", property)
|
|
|
|
default:
|
|
return "", fmt.Errorf("unknown variable source: %s", source)
|
|
}
|
|
}
|
|
|
|
func (e *Expander) resolveNow(property string, now time.Time) (string, error) {
|
|
switch property {
|
|
case "utc_date":
|
|
return now.Format("2006-01-02"), nil
|
|
case "utc_time":
|
|
return now.Format("15:04:05"), nil
|
|
case "utc_datetime":
|
|
return now.Format("2006-01-02T15:04:05Z"), nil
|
|
case "year":
|
|
return now.Format("2006"), nil
|
|
case "month":
|
|
return now.Format("01"), nil
|
|
case "day":
|
|
return now.Format("02"), nil
|
|
case "hour":
|
|
return now.Format("15"), nil
|
|
case "minute":
|
|
return now.Format("04"), nil
|
|
default:
|
|
return "", fmt.Errorf("unknown now property: %s", property)
|
|
}
|
|
}
|
|
|
|
// ListVariables extracts all variable references from a template
|
|
func (e *Expander) ListVariables(template string) []string {
|
|
matches := e.variablePattern.FindAllString(template, -1)
|
|
unique := make(map[string]bool)
|
|
var result []string
|
|
|
|
for _, m := range matches {
|
|
if !unique[m] {
|
|
unique[m] = true
|
|
result = append(result, m)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ValidateTemplate checks if a template has valid variable syntax
|
|
func (e *Expander) ValidateTemplate(template string) []string {
|
|
var warnings []string
|
|
|
|
// Check for unclosed braces
|
|
openCount := strings.Count(template, "{{")
|
|
closeCount := strings.Count(template, "}}")
|
|
|
|
if openCount != closeCount {
|
|
warnings = append(warnings, "mismatched variable delimiters")
|
|
}
|
|
|
|
return warnings
|
|
}
|