Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
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>
2026-09-03 13:57:15 -04:00

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
}