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

198 lines
6.4 KiB
Go

package engine
import (
"encoding/json"
"sort"
"testing"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
)
// fakeClient is an in-memory membershipClient for reconciliation tests.
type fakeClient struct {
members map[string][]string // groupDN -> member DNs
exists map[string]bool
created []string
}
func (f *fakeClient) Exists(dn string) (bool, error) { return f.exists[dn], nil }
func (f *fakeClient) GetGroupMembers(groupDN string) ([]string, error) {
return append([]string(nil), f.members[groupDN]...), nil
}
func (f *fakeClient) AddGroupMember(groupDN, memberDN string) error {
f.members[groupDN] = append(f.members[groupDN], memberDN)
return nil
}
func (f *fakeClient) RemoveGroupMember(groupDN, memberDN string) error {
cur := f.members[groupDN]
out := cur[:0]
for _, m := range cur {
if m != memberDN {
out = append(out, m)
}
}
f.members[groupDN] = out
return nil
}
func (f *fakeClient) CreateGroup(groupDN, groupType, groupScope string) error {
f.created = append(f.created, groupDN)
f.exists[groupDN] = true
f.members[groupDN] = nil
return nil
}
func (f *fakeClient) EnsureOUPath(ouDN string) error { return nil }
// fakeStore is an in-memory ManagedMemberStore.
type fakeStore struct {
m map[string]map[string]bool // ruleID|groupDN -> set of member DNs
}
func newFakeStore() *fakeStore { return &fakeStore{m: map[string]map[string]bool{}} }
func (s *fakeStore) key(ruleID, groupDN string) string { return ruleID + "|" + groupDN }
func (s *fakeStore) List(ruleID, groupDN string) ([]string, error) {
var out []string
for dn := range s.m[s.key(ruleID, groupDN)] {
out = append(out, dn)
}
return out, nil
}
func (s *fakeStore) Add(ruleID, groupDN, memberDN string) error {
k := s.key(ruleID, groupDN)
if s.m[k] == nil {
s.m[k] = map[string]bool{}
}
s.m[k][memberDN] = true
return nil
}
func (s *fakeStore) Remove(ruleID, groupDN, memberDN string) error {
delete(s.m[s.key(ruleID, groupDN)], memberDN)
return nil
}
func syncAction(t *testing.T, group, mode string, createIfMissing bool) *models.RuleAction {
t.Helper()
cfg := models.ActionConfig{TargetGroupDN: group, SyncMode: mode, CreateIfMissing: createIfMissing}
b, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal cfg: %v", err)
}
return &models.RuleAction{ID: "action-1", ActionType: string(types.ActionSyncGroupMembership), ConfigurationJSON: string(b), IsEnabled: true}
}
func sortedMembers(f *fakeClient, group string) []string {
out := append([]string(nil), f.members[group]...)
sort.Strings(out)
return out
}
const grp = "CN=Dyn,OU=Groups,DC=x,DC=y"
func TestReconcileFullSync(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{
members: map[string][]string{grp: {"CN=A,DC=x,DC=y", "CN=B,DC=x,DC=y", "CN=X,DC=x,DC=y"}},
exists: map[string]bool{grp: true},
}
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=A,DC=x,DC=y", "CN=B,DC=x,DC=y", "CN=C,DC=x,DC=y"}
results := eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeFull), false), rule, matched, f, newFakeStore())
// Expect: add C, remove X.
if got := sortedMembers(f, grp); len(got) != 3 || got[0] != "CN=A,DC=x,DC=y" || got[1] != "CN=B,DC=x,DC=y" || got[2] != "CN=C,DC=x,DC=y" {
t.Fatalf("full sync membership wrong: %v", got)
}
var adds, removes int
for _, r := range results {
if !r.Success {
t.Errorf("unexpected failure: %+v", r)
}
switch r.ActionType {
case string(types.ActionAddToGroup):
adds++
case string(types.ActionRemoveFromGroupIfNoMatch):
removes++
}
}
if adds != 1 || removes != 1 {
t.Errorf("expected 1 add / 1 remove, got %d / %d", adds, removes)
}
}
func TestReconcileManagedAddLeavesManualMembers(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{
members: map[string][]string{grp: {"CN=B,DC=x,DC=y", "CN=X,DC=x,DC=y", "CN=Manual,DC=x,DC=y"}},
exists: map[string]bool{grp: true},
}
store := newFakeStore()
// The rule previously added B and X.
_ = store.Add("rule-1", grp, "CN=B,DC=x,DC=y")
_ = store.Add("rule-1", grp, "CN=X,DC=x,DC=y")
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=B,DC=x,DC=y"} // X no longer matches; Manual was never managed
eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeManaged), false), rule, matched, f, store)
got := sortedMembers(f, grp)
// B stays (matched), X removed (managed + unmatched), Manual stays (not managed).
if len(got) != 2 || got[0] != "CN=B,DC=x,DC=y" || got[1] != "CN=Manual,DC=x,DC=y" {
t.Fatalf("managed sync membership wrong: %v", got)
}
}
func TestReconcileAddOnlyNeverRemoves(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{
members: map[string][]string{grp: {"CN=Stale,DC=x,DC=y"}},
exists: map[string]bool{grp: true},
}
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=A,DC=x,DC=y"}
eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeAddOnly), false), rule, matched, f, newFakeStore())
got := sortedMembers(f, grp)
if len(got) != 2 || got[0] != "CN=A,DC=x,DC=y" || got[1] != "CN=Stale,DC=x,DC=y" {
t.Fatalf("add-only membership wrong: %v", got)
}
}
func TestReconcileCreatesMissingGroup(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{members: map[string][]string{}, exists: map[string]bool{}}
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=A,DC=x,DC=y"}
results := eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeFull), true), rule, matched, f, newFakeStore())
if len(f.created) != 1 || f.created[0] != grp {
t.Fatalf("expected group to be created, created=%v", f.created)
}
if got := sortedMembers(f, grp); len(got) != 1 || got[0] != "CN=A,DC=x,DC=y" {
t.Fatalf("membership after create wrong: %v", got)
}
var sawCreate bool
for _, r := range results {
if r.ActionType == string(types.ActionEnsureGroupExists) {
sawCreate = true
}
}
if !sawCreate {
t.Errorf("expected an EnsureGroupExists result")
}
}
func TestReconcileMissingGroupNoCreateFails(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{members: map[string][]string{}, exists: map[string]bool{}}
rule := &models.Rule{ID: "rule-1"}
results := eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeFull), false), rule, []string{"CN=A,DC=x,DC=y"}, f, newFakeStore())
if len(results) != 1 || results[0].Success {
t.Fatalf("expected a single failure result, got %+v", results)
}
}