security(import): never import policy rules from archives
An archive is data about a mind, not a grant of permissions on this instance. Import now skips the policies table (forks on the same instance keep theirs), reports the omitted rows in the audit log and the mind's history, and the snapshot test proves both paths.
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
A **snapshot** is a JSON archive of a mind's state at a point in its history:
|
A **snapshot** is a JSON archive of a mind's state at a point in its history:
|
||||||
identity, personality, configuration, workspace and working memory, drives and
|
identity, personality, configuration, workspace and working memory, drives and
|
||||||
affect, goals, beliefs (with evidence, versions, alternatives, links), memories
|
affect, goals, beliefs (with evidence, versions, alternatives, links), memories
|
||||||
(with relationships), predictions, entities, corrections, policies and
|
(with relationships), predictions, entities, corrections, policy rules (kept by forks, never imported from archives) and
|
||||||
conversations. It records the event sequence number at capture and a SHA-256
|
conversations. It records the event sequence number at capture and a SHA-256
|
||||||
checksum. Cognitive events are **not** copied.
|
checksum. Cognitive events are **not** copied.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -190,15 +191,19 @@ func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, b
|
|||||||
if err := dec.Decode(&a); err != nil {
|
if err := dec.Decode(&a); err != nil {
|
||||||
return minds.Mind{}, ValidationError("invalid archive: " + err.Error())
|
return minds.Mind{}, ValidationError("invalid archive: " + err.Error())
|
||||||
}
|
}
|
||||||
m, err := s.deps.Snapshots.Import(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug})
|
m, omitted, err := s.deps.Snapshots.ImportReport(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, snapshots.ErrInvalid) {
|
if errors.Is(err, snapshots.ErrInvalid) {
|
||||||
return m, ValidationError(err.Error())
|
return m, ValidationError(err.Error())
|
||||||
}
|
}
|
||||||
return m, mindErr(err)
|
return m, mindErr(err)
|
||||||
}
|
}
|
||||||
s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts()})
|
s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts(), "omitted": omitted})
|
||||||
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", "Mind imported from archive of "+a.Source.Slug+" ("+a.Checksum+")", map[string]any{"counts": a.Counts()})
|
summary := "Mind imported from archive of " + a.Source.Slug + " (" + a.Checksum + ")"
|
||||||
|
if n := omitted["policies"]; n > 0 {
|
||||||
|
summary += fmt.Sprintf("; %d policy rule(s) in the archive were not imported: policies are set by this instance's operators", n)
|
||||||
|
}
|
||||||
|
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", summary, map[string]any{"counts": a.Counts(), "omitted": omitted})
|
||||||
s.deps.Brain.Reconcile(ctx) //nolint:errcheck
|
s.deps.Brain.Reconcile(ctx) //nolint:errcheck
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,13 +248,21 @@ func contains(xs []string, x string) bool {
|
|||||||
|
|
||||||
var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||||
|
|
||||||
|
// AuthorityTables hold rows that grant or shape authority rather than
|
||||||
|
// describe cognitive state. They are copied by Fork (same operator, same
|
||||||
|
// instance) but never by Import: an archive from elsewhere must not be able
|
||||||
|
// to arrive carrying ALLOW rules for a mind (THREAT_MODEL T16).
|
||||||
|
var AuthorityTables = map[string]bool{"policies": true}
|
||||||
|
|
||||||
// Restore writes an archive's rows into mind `into`. Every row id found in
|
// Restore writes an archive's rows into mind `into`. Every row id found in
|
||||||
// the archive gets a fresh id and all references to it (any uuid-shaped
|
// the archive gets a fresh id and all references to it (any uuid-shaped
|
||||||
// string anywhere in the rows, including arrays and JSON detail) follow.
|
// string anywhere in the rows, including arrays and JSON detail) follow.
|
||||||
// References to ids not in the archive (events, templates) are kept: that
|
// References to ids not in the archive (events, templates) are kept: that
|
||||||
// is how history stays shared by reference. Must run in the caller's
|
// is how history stays shared by reference. Must run in the caller's
|
||||||
// transaction after the mind row exists.
|
// transaction after the mind row exists. Tables named in skip are not
|
||||||
func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID) error {
|
// written; the returned map reports how many rows were omitted per table.
|
||||||
|
func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID, skip map[string]bool) (map[string]int, error) {
|
||||||
|
omitted := map[string]int{}
|
||||||
remap := map[string]string{a.Source.MindID.String(): into.String()}
|
remap := map[string]string{a.Source.MindID.String(): into.String()}
|
||||||
for _, t := range Tables {
|
for _, t := range Tables {
|
||||||
for _, r := range a.Tables[t] {
|
for _, r := range a.Tables[t] {
|
||||||
@@ -270,9 +278,13 @@ func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive
|
|||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if skip[t] {
|
||||||
|
omitted[t] = len(rows)
|
||||||
|
continue
|
||||||
|
}
|
||||||
cs := cols[t]
|
cs := cols[t]
|
||||||
if len(cs) == 0 {
|
if len(cs) == 0 {
|
||||||
return fmt.Errorf("%w: table %s not present", ErrInvalid, t)
|
return omitted, fmt.Errorf("%w: table %s not present", ErrInvalid, t)
|
||||||
}
|
}
|
||||||
conflict := ""
|
conflict := ""
|
||||||
if key, ok := keyedByMind[t]; ok {
|
if key, ok := keyedByMind[t]; ok {
|
||||||
@@ -291,11 +303,11 @@ func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive
|
|||||||
row["mind_id"] = into.String()
|
row["mind_id"] = into.String()
|
||||||
b, _ := json.Marshal(row)
|
b, _ := json.Marshal(row)
|
||||||
if _, err := tx.Exec(ctx, sql, b); err != nil {
|
if _, err := tx.Exec(ctx, sql, b); err != nil {
|
||||||
return fmt.Errorf("restore %s: %w", t, err)
|
return omitted, fmt.Errorf("restore %s: %w", t, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return omitted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func remapValue(v any, remap map[string]string) any {
|
func remapValue(v any, remap map[string]string) any {
|
||||||
@@ -420,6 +432,10 @@ type ForkOptions struct {
|
|||||||
Config *minds.CognitiveConfig
|
Config *minds.CognitiveConfig
|
||||||
Personality *minds.Personality
|
Personality *minds.Personality
|
||||||
By string
|
By string
|
||||||
|
// OmitAuthority skips AuthorityTables (policies). Import sets it; Fork does not.
|
||||||
|
OmitAuthority bool
|
||||||
|
// Omitted is filled by the restore with the rows skipped per table.
|
||||||
|
Omitted map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fork creates a new mind from a snapshot. The new mind's state equals the
|
// Fork creates a new mind from a snapshot. The new mind's state equals the
|
||||||
@@ -430,7 +446,7 @@ func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (min
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return minds.Mind{}, err
|
return minds.Mind{}, err
|
||||||
}
|
}
|
||||||
m, err := s.restoreNew(ctx, a, o)
|
m, err := s.restoreNew(ctx, a, &o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
@@ -441,14 +457,26 @@ func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (min
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import validates a hostile archive and restores it into a new mind.
|
// Import validates a hostile archive and restores it into a new mind.
|
||||||
|
// Authority-bearing tables are never imported: an archive is data about a
|
||||||
|
// mind, not a grant of permissions on this instance. The number of skipped
|
||||||
|
// rows is reported in o.Omitted when the caller passes a pointer-free copy;
|
||||||
|
// use ImportReport to receive it.
|
||||||
func (s *Store) Import(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) {
|
func (s *Store) Import(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) {
|
||||||
if err := Validate(a); err != nil {
|
m, _, err := s.ImportReport(ctx, a, o)
|
||||||
return minds.Mind{}, err
|
return m, err
|
||||||
}
|
|
||||||
return s.restoreNew(ctx, a, o)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) {
|
// ImportReport is Import that also returns the rows omitted per table.
|
||||||
|
func (s *Store) ImportReport(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, map[string]int, error) {
|
||||||
|
if err := Validate(a); err != nil {
|
||||||
|
return minds.Mind{}, nil, err
|
||||||
|
}
|
||||||
|
o.OmitAuthority = true
|
||||||
|
m, err := s.restoreNew(ctx, a, &o)
|
||||||
|
return m, o.Omitted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) restoreNew(ctx context.Context, a Archive, o *ForkOptions) (minds.Mind, error) {
|
||||||
cfg := a.Mind.Config
|
cfg := a.Mind.Config
|
||||||
if o.Config != nil {
|
if o.Config != nil {
|
||||||
cfg = *o.Config
|
cfg = *o.Config
|
||||||
@@ -477,8 +505,14 @@ func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
|
var skip map[string]bool
|
||||||
|
if o.OmitAuthority {
|
||||||
|
skip = AuthorityTables
|
||||||
|
}
|
||||||
err = storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
|
err = storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
|
||||||
if err := Restore(ctx, tx, cols, a, m.ID); err != nil {
|
omitted, err := Restore(ctx, tx, cols, a, m.ID, skip)
|
||||||
|
o.Omitted = omitted
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if o.Status != "" && o.Status != minds.StatusActive {
|
if o.Status != "" && o.Status != minds.StatusActive {
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ func TestSnapshotForkImportCompare(t *testing.T) {
|
|||||||
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: string(m1.ID), Source: "user", Weight: 0.5, Credibility: 0.9}}}); err != nil {
|
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: string(m1.ID), Source: "user", Weight: 0.5, Credibility: 0.9}}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// An ALLOW rule travels with a fork (same operator) but never with an import.
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect, reason, created_by) VALUES ($1,$2,'http.fetch','*','*','allow','test','andy')`, ids.New(), src.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
st := &Store{DB: pool, Events: es, Minds: ms}
|
st := &Store{DB: pool, Events: es, Minds: ms}
|
||||||
sn, err := st.Create(ctx, src, "before", "test snapshot", "andy")
|
sn, err := st.Create(ctx, src, "before", "test snapshot", "andy")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,10 +114,16 @@ func TestSnapshotForkImportCompare(t *testing.T) {
|
|||||||
if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil {
|
if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil {
|
||||||
t.Fatal("unknown table accepted")
|
t.Fatal("unknown table accepted")
|
||||||
}
|
}
|
||||||
imp, err := st.Import(ctx, a, ForkOptions{Slug: "imported", By: "andy"})
|
imp, omitted, err := st.ImportReport(ctx, a, ForkOptions{Slug: "imported", By: "andy"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
var forkPolicies, importPolicies int
|
||||||
|
pool.QueryRow(ctx, `SELECT count(*) FROM policies WHERE mind_id=$1`, fork.ID).Scan(&forkPolicies) //nolint:errcheck
|
||||||
|
pool.QueryRow(ctx, `SELECT count(*) FROM policies WHERE mind_id=$1`, imp.ID).Scan(&importPolicies) //nolint:errcheck
|
||||||
|
if forkPolicies != 1 || importPolicies != 0 || omitted["policies"] != 1 {
|
||||||
|
t.Fatalf("policy handling: fork=%d import=%d omitted=%v", forkPolicies, importPolicies, omitted)
|
||||||
|
}
|
||||||
if im, _ := mem.List(ctx, imp.ID, memory.Filter{Limit: 10}); len(im) != 2 {
|
if im, _ := mem.List(ctx, imp.ID, memory.Filter{Limit: 10}); len(im) != 2 {
|
||||||
t.Fatalf("import memories: %d", len(im))
|
t.Fatalf("import memories: %d", len(im))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user