diff --git a/README.md b/README.md index 7096ab3..52883cc 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ re-approves a fingerprint without repeating its label silently blanks it, losing the annotation that makes the row identifiable. `UpsertStatus` does take both, because the control plane is authoritative for both. +## Scoped approvals + +See [scoped approval integration](docs/scoped-approvals.md) before enabling +client-CIDR restrictions. Gatekit validates and persists restrictions; each gate +remains responsible for enforcing them before advertising protocol support. + ## Migrating an existing gate database sshgate and tlsgate both have databases in service, with protocol fields in diff --git a/approval/scope.go b/approval/scope.go new file mode 100644 index 0000000..5c183ac --- /dev/null +++ b/approval/scope.go @@ -0,0 +1,90 @@ +// Package approval validates client-address restrictions on fingerprint approvals. +package approval + +import ( + "encoding/json" + "fmt" + "net/netip" + "sort" +) + +const Capability = "approval_ranges_v1" +const MaxRanges = 128 + +// Scope is an immutable set of client CIDRs. A nil *Scope means unrestricted. +// Its zero value is invalid, preventing an explicit empty scope from broadening trust. +type Scope struct{ prefixes []netip.Prefix } + +func New(ranges []string) (*Scope, error) { + if len(ranges) == 0 || len(ranges) > MaxRanges { + return nil, fmt.Errorf("approval_ranges requires 1..%d CIDRs", MaxRanges) + } + seen := make(map[netip.Prefix]bool) + for _, raw := range ranges { + p, err := netip.ParsePrefix(raw) + if err != nil || p.Addr().Is4In6() { + return nil, fmt.Errorf("invalid approval CIDR %q", raw) + } + seen[p.Masked()] = true + } + s := &Scope{} + for p := range seen { + s.prefixes = append(s.prefixes, p) + } + sort.Slice(s.prefixes, func(i, j int) bool { return s.prefixes[i].String() < s.prefixes[j].String() }) + return s, nil +} + +func (s *Scope) Validate() error { + if s != nil && (len(s.prefixes) == 0 || len(s.prefixes) > MaxRanges) { + return fmt.Errorf("invalid empty or oversized approval scope") + } + return nil +} + +func (s *Scope) Ranges() []string { + if s == nil { + return nil + } + out := make([]string, len(s.prefixes)) + for i, p := range s.prefixes { + out[i] = p.String() + } + return out +} + +// Allows uses the transport peer's address, not a hostname or untrusted header. +func (s *Scope) Allows(addr netip.Addr) bool { + if s == nil { + return true + } + if !addr.IsValid() || addr.Zone() != "" { + return false + } + addr = addr.Unmap() + for _, p := range s.prefixes { + if p.Contains(addr) { + return true + } + } + return false +} + +func (s *Scope) MarshalJSON() ([]byte, error) { + if err := s.Validate(); err != nil { + return nil, err + } + return json.Marshal(s.Ranges()) +} +func (s *Scope) UnmarshalJSON(data []byte) error { + var ranges []string + if err := json.Unmarshal(data, &ranges); err != nil { + return err + } + parsed, err := New(ranges) + if err != nil { + return err + } + *s = *parsed + return nil +} diff --git a/approval/scope_test.go b/approval/scope_test.go new file mode 100644 index 0000000..2dcfbf9 --- /dev/null +++ b/approval/scope_test.go @@ -0,0 +1,38 @@ +package approval + +import ( + "encoding/json" + "net/netip" + "testing" +) + +func TestScopeValidationAndMatching(t *testing.T) { + for _, raw := range []string{`[]`, `["bad"]`, `["::ffff:192.0.2.0/120"]`, `["fe80::1%eth0/64"]`, `[123]`} { + var s *Scope + if err := json.Unmarshal([]byte(raw), &s); err == nil { + t.Fatalf("accepted %s", raw) + } + } + s, err := New([]string{"192.0.2.9/24", "2001:db8:1::1/64", "192.0.2.0/24"}) + if err != nil { + t.Fatal(err) + } + if len(s.Ranges()) != 2 { + t.Fatal(s.Ranges()) + } + for ip, want := range map[string]bool{"192.0.2.25": true, "::ffff:192.0.2.25": true, "192.0.3.25": false, "2001:db8:1::5": true, "2001:db8:2::5": false, "fe80::1%eth0": false} { + if got := s.Allows(netip.MustParseAddr(ip)); got != want { + t.Errorf("%s: %t", ip, got) + } + } + if s.Allows(netip.Addr{}) { + t.Fatal("invalid address allowed") + } + var unrestricted *Scope + if !unrestricted.Allows(netip.MustParseAddr("198.51.100.1")) { + t.Fatal("legacy scope changed") + } + if _, err := json.Marshal(&Scope{}); err == nil { + t.Fatal("empty scope marshaled") + } +} diff --git a/controlplane/controlplane.go b/controlplane/controlplane.go index f321f55..b42747b 100644 --- a/controlplane/controlplane.go +++ b/controlplane/controlplane.go @@ -6,6 +6,7 @@ package controlplane import ( "bytes" "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/json" @@ -17,6 +18,7 @@ import ( "strings" "time" + "github.com/kilo666mj/gatekit/approval" "github.com/kilo666mj/gatekit/store" ) @@ -31,14 +33,16 @@ const ( // Config is the control_plane block of a gate's config file. type Config struct { - URL string `json:"url"` - InstanceID string `json:"instance_id"` - Token string `json:"token"` - ClientCert string `json:"client_cert"` - ClientKey string `json:"client_key"` - CA string `json:"ca"` - ServerName string `json:"server_name"` - SyncInterval string `json:"sync_interval"` + // SupportsApprovalRanges must only be set by gates that enforce scoped approvals. + SupportsApprovalRanges bool `json:"-"` + URL string `json:"url"` + InstanceID string `json:"instance_id"` + Token string `json:"token"` + ClientCert string `json:"client_cert"` + ClientKey string `json:"client_key"` + CA string `json:"ca"` + ServerName string `json:"server_name"` + SyncInterval string `json:"sync_interval"` // ApplyTrustedRanges atomically replaces control-plane managed source // bypasses. It is runtime wiring, not serialized configuration. ApplyTrustedRanges func([]string) error `json:"-"` @@ -83,16 +87,17 @@ func (cfg Config) Interval() time.Duration { } type observation struct { - Fingerprint string `json:"fingerprint"` - Status store.Status `json:"status"` - Label string `json:"label,omitempty"` - FirstSeen string `json:"first_seen,omitempty"` - LastSeen string `json:"last_seen,omitempty"` - IPs []string `json:"ips,omitempty"` - Ports []int `json:"ports,omitempty"` - Sightings []store.Sighting `json:"sightings,omitempty"` - Count int `json:"count,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + ApprovalRanges *approval.Scope `json:"approval_ranges,omitempty"` + Fingerprint string `json:"fingerprint"` + Status store.Status `json:"status"` + Label string `json:"label,omitempty"` + FirstSeen string `json:"first_seen,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + IPs []string `json:"ips,omitempty"` + Ports []int `json:"ports,omitempty"` + Sightings []store.Sighting `json:"sightings,omitempty"` + Count int `json:"count,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } type observationBatch struct { @@ -106,19 +111,15 @@ type policyResponse struct { TrustedRanges *[]string `json:"trusted_ranges,omitempty"` } -type decision struct { - Fingerprint string `json:"fingerprint"` - Status store.Status `json:"status"` - Label string `json:"label,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} +type decision = store.Decision // Syncer pushes observations to gatehub and applies returned policy. type Syncer struct { - store *store.Store - cfg Config - client *http.Client - cursor string + store *store.Store + cfg Config + client *http.Client + cursor string + cursorKey string } // New validates configuration and builds a Syncer. @@ -130,7 +131,12 @@ func New(st *store.Store, cfg Config) (*Syncer, error) { if err != nil { return nil, err } - return &Syncer{store: st, cfg: cfg, client: client}, nil + cursorKey := fmt.Sprintf("gatehub_cursor:%x", sha256.Sum256([]byte(cfg.URL+"\x00"+cfg.InstanceID))) + cursor, err := st.GetMeta(cursorKey) + if err != nil { + return nil, err + } + return &Syncer{store: st, cfg: cfg, client: client, cursor: cursor, cursorKey: cursorKey}, nil } // Start begins syncing in the background until ctx is cancelled. It is a no-op @@ -273,17 +279,14 @@ func (s *Syncer) pullPolicy(ctx context.Context) (err error) { return err } for _, d := range policy.Decisions { - if d.Fingerprint == "" { - continue + if err := d.Validate(); err != nil { + return fmt.Errorf("invalid policy: %w", err) } - if !d.Status.Valid() { - log.Printf("gatehub policy ignored invalid status %q for %s", d.Status, d.Fingerprint) - continue - } - if err := s.store.UpsertStatus(d.Fingerprint, d.Status, d.Label); err != nil { - return fmt.Errorf("apply decision for %s: %w", d.Fingerprint, err) + if d.ApprovalRanges != nil && !s.cfg.SupportsApprovalRanges { + return fmt.Errorf("gate does not enforce approval_ranges") } } + // A pointer distinguishes an older Gatehub that omitted the field from a // current Gatehub intentionally publishing an empty trusted set. if policy.TrustedRanges != nil && s.cfg.ApplyTrustedRanges != nil { @@ -291,6 +294,9 @@ func (s *Syncer) pullPolicy(ctx context.Context) (err error) { return fmt.Errorf("apply trusted ranges: %w", err) } } + if err := s.store.ApplyDecisions(policy.Decisions, s.cursorKey, policy.Cursor); err != nil { + return fmt.Errorf("apply policy: %w", err) + } if policy.Cursor != "" { s.cursor = policy.Cursor } @@ -298,6 +304,9 @@ func (s *Syncer) pullPolicy(ctx context.Context) (err error) { } func (s *Syncer) setAuth(req *http.Request) { + if s.cfg.SupportsApprovalRanges { + req.Header.Set("X-Gatekit-Capabilities", approval.Capability) + } if s.cfg.Token != "" { req.Header.Set("Authorization", "Bearer "+s.cfg.Token) } @@ -308,16 +317,17 @@ func (s *Syncer) setAuth(req *http.Request) { // which is what lets one syncer serve every gate. func toObservation(fp string, entry store.Entry) observation { return observation{ - Fingerprint: fp, - Status: entry.Status, - Label: entry.Label, - FirstSeen: entry.FirstSeen.UTC().Format(time.RFC3339Nano), - LastSeen: entry.LastSeen.UTC().Format(time.RFC3339Nano), - IPs: limited(entry.IPs, maxObservationValues), - Ports: limited(entry.Ports, maxObservationValues), - Sightings: limited(entry.Sightings, maxObservationValues), - Count: entry.Count, - Metadata: entry.Meta, + Fingerprint: fp, + ApprovalRanges: entry.ApprovalRanges, + Status: entry.Status, + Label: entry.Label, + FirstSeen: entry.FirstSeen.UTC().Format(time.RFC3339Nano), + LastSeen: entry.LastSeen.UTC().Format(time.RFC3339Nano), + IPs: limited(entry.IPs, maxObservationValues), + Ports: limited(entry.Ports, maxObservationValues), + Sightings: limited(entry.Sightings, maxObservationValues), + Count: entry.Count, + Metadata: entry.Meta, } } diff --git a/controlplane/controlplane_test.go b/controlplane/controlplane_test.go index c1ce44d..541d320 100644 --- a/controlplane/controlplane_test.go +++ b/controlplane/controlplane_test.go @@ -172,8 +172,6 @@ func TestPullPolicyAppliesDecisions(t *testing.T) { Decisions: []decision{ {Fingerprint: "known", Status: store.StatusBlocked, Label: "bad"}, {Fingerprint: "unseen", Status: store.StatusApproved, Label: "preapproved"}, - {Fingerprint: "", Status: store.StatusApproved}, - {Fingerprint: "junk", Status: store.Status("nonsense")}, }, }); err != nil { t.Errorf("encode policy response: %v", err) @@ -205,10 +203,6 @@ func TestPullPolicyAppliesDecisions(t *testing.T) { if unseen.Status != store.StatusApproved { t.Errorf("unseen = %+v", unseen) } - // An unparseable status is skipped, not applied and not fatal. - if _, err := st.Get("junk"); err == nil { - t.Error("invalid status was applied") - } if err := s.PullPolicy(); err != nil { t.Fatalf("second PullPolicy: %v", err) diff --git a/controlplane/scoped_test.go b/controlplane/scoped_test.go new file mode 100644 index 0000000..4729179 --- /dev/null +++ b/controlplane/scoped_test.go @@ -0,0 +1,62 @@ +package controlplane + +import ( + "github.com/kilo666mj/gatekit/store" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestScopedPolicyCapabilityAndAtomicRejection(t *testing.T) { + st := openStore(t) + body := `{"cursor":"1","decisions":[{"fingerprint":"one","status":"approved","approval_ranges":["192.0.2.0/24"]}]}` + var capability string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capability = r.Header.Get("X-Gatekit-Capabilities") + if _, err := io.WriteString(w, body); err != nil { + t.Error(err) + } + })) + defer srv.Close() + cfg := testTLSConfig(t, srv, "t") + s, err := New(st, cfg) + if err != nil { + t.Fatal(err) + } + if err := s.PullPolicy(); err == nil { + t.Fatal("unsupported gate accepted scope") + } + cfg.SupportsApprovalRanges = true + s, err = New(st, cfg) + if err != nil { + t.Fatal(err) + } + if err := s.PullPolicy(); err != nil { + t.Fatal(err) + } + if capability != "approval_ranges_v1" { + t.Fatal(capability) + } + e, err := st.Get("one") + if err != nil || e.ApprovalRanges == nil { + t.Fatalf("missing scope %+v %v", e, err) + } + for _, bad := range []string{`{"fingerprint":"two","status":"approved","approval_ranges":[]}`, `{"fingerprint":"two","status":"invalid"}`, `{"fingerprint":"","status":"approved"}`} { + body = `{"cursor":"2","decisions":[{"fingerprint":"one","status":"blocked"},` + bad + `]}` + if err := s.PullPolicy(); err == nil { + t.Fatal("accepted invalid batch") + } + e, err = st.Get("one") + if err != nil || e.Status != store.StatusApproved || e.ApprovalRanges == nil { + t.Fatalf("partially applied: %+v %v", e, err) + } + if s.cursor != "1" { + t.Fatal(s.cursor) + } + } + restarted, err := New(st, cfg) + if err != nil || restarted.cursor != "1" { + t.Fatalf("cursor not persisted: %v", err) + } +} diff --git a/docs/scoped-approvals.md b/docs/scoped-approvals.md new file mode 100644 index 0000000..478a474 --- /dev/null +++ b/docs/scoped-approvals.md @@ -0,0 +1,144 @@ +# Scoped fingerprint approvals: implementation design + +Status: implemented library and integration contract. Gatekit provides the +`approval` package, scoped store entries and atomic policy synchronization. A +consumer must explicitly opt into `SupportsApprovalRanges` only after enforcing +the scope. Existing unscoped approvals retain their behavior. + +## Purpose and boundaries + +A common TLS-library fingerprint is shared by unrelated clients. An operator +must be able to approve it only when the connection source also falls within a +specified set of CIDRs. Scope is an intersection with the fingerprint approval; +it is distinct from TLSGate trusted ranges, which bypass fingerprint policy. +Backend authentication remains required. + +Gatekit owns parsing, persistence and synchronization. Gatehub owns decision +selection and the operator interface. TLSGate owns the forwarding verdict and +shadow-mode logs. Gatehub's existing instance/kind/global decision scope selects +**nodes**; the new approval scope restricts **client addresses**. Keep the two +concepts separate in APIs and labels. + +## Data model and validation + +Add `approval_ranges` to store entries, Gatehub decisions, observations and policy +responses. Missing or JSON null means unrestricted, for compatibility with old +unscoped records. A nonempty array of CIDR strings means restricted. An explicit +empty array is invalid; never silently interpret it as unrestricted. The UI must +provide a separate explicit action for removing all restrictions. + +Parse with `net/netip`, reject hostnames, zone identifiers, malformed CIDRs and +IPv4-mapped IPv6 prefixes. Normalize accepted prefixes with `Masked`, deduplicate, +and sort deterministically. Accept IPv4 and IPv6 together, with OR semantics +inside the list. Unmap incoming IPv4-mapped addresses before matching IPv4 +prefixes. A missing or unparsable connection address never matches a restriction. +Require at least one and at most 128 CIDRs; reject larger requests before storage. + +Scopes apply only to approved decisions. Reject a non-null scope on pending or +blocked decisions. Changing a restricted approval to pending/blocked clears its +scope atomically. Reapproving without an explicit scope choice must not revive +or silently broaden the former approval. Existing status-only setters must +reject operations on a restricted approved entry when they cannot express this +choice; observation updates must always preserve the stored decision and scope. + +## Storage and atomic policy application + +Add a nullable JSON column through Gatekit's additive migration mechanism and +Gatehub's schema migration. Existing rows receive SQL NULL. Store validation +must run even for local callers, not just HTTP requests. Corrupt non-null stored +scope must return an error and cannot become an unrestricted approval. + +Introduce one decision-write operation that changes status, label and scope in +one transaction. Do not apply `UpsertStatus` followed by a separate scope update: +that exposes a temporarily unrestricted approval to concurrent connections. +`Observe`, pruning and observation synchronization preserve operator scope. + +Policy synchronization validates the complete returned batch first, then applies +its decisions and cursor in one transaction. On malformed scope, database error +or unsupported scope capability, retain the prior decisions and cursor and emit +a bounded error. Retrying a batch must be idempotent. Preserve existing decision +ordering and precedence; scopes are part of the selected decision, never unioned +across global, kind and instance decisions. + +## Mixed-version protocol + +An old client ignores unknown JSON fields. Adding a field alone is therefore +unsafe: it could receive an approved decision while discarding its restriction. +Add explicit node capability negotiation (`approval_ranges_v1`) before Gatehub +may send restricted approvals. The new Gatehub must reject creating a restricted +decision for any targeted node lacking this capability, including kind/global +targets. New node registration and capability changes revalidate applicable +restricted decisions. An incompatible policy pull must fail visibly rather than +serialize a restricted decision as an unrestricted approval. + +A failed pull does not revoke an older cached unrestricted approval. Rollout must +therefore upgrade and verify every affected node **before** installing its first +restricted approval. A node must not advertise the capability until enforcement, +atomic storage and parsing are all present. Capability loss/downgrade with active +restricted approvals is prohibited operationally; stop the gate or first replace +those approvals with blocks and verify synchronization before rollback. Database +compatibility alone does not make an older binary safe to run. + +## TLSGate decision order + +Preserve the existing explicit trusted-range bypass and log it as `WHITELIST`. +The UI and operations guide must state that trusted ranges still bypass scoped +approval checks. An operator wanting an intersection must not put those clients +in trusted ranges merely to make the scoped approval work. + +For all other clients, an approved unscoped fingerprint keeps current behavior. +An approved restricted fingerprint passes only when the source matches its CIDRs. +An out-of-scope approval is explicitly denied even on an observe/allow-unknown +listener; treating it as an ordinary unknown there would defeat the restriction. +Use a distinct `out_of_scope` log reason without changing the stored approval or +adding a blanket block. Ordinary pending/blocked behavior stays unchanged. + +An opt-in shadow mode records `would_block_out_of_scope` while retaining the +previous forwarding result. Logs identify shadow/enforced mode and include the +existing fingerprint and client context, without treating shadow as enforcement. +Gatekit decodes scopes into immutable prefix lists with each returned entry; +never reuse a verdict cached solely by fingerprint for different client IPs. + +## Implementation and verification sequence + +1. Gatekit: typed scope parser, migration, atomic decision and cursor writes, + store APIs and synchronization tests. Publish a tagged release. +2. TLSGate: pin that release, enforce matching, add shadow mode and explicit CLI + scope choices. Do not advertise capability from storage-only intermediate code. +3. Gatehub: migrate decisions, API/UI validation, capability checks and visible + scope display. Do not enable restricted writes before affected nodes upgrade. +4. Validate both gates against the released Gatekit, including SSHGate's unchanged + unscoped behavior. Release and deploy with the existing Ansible workflow. +5. For the intended mail-client approval, verify the current source prefix through + private deployment inventory, run shadow mode, review matching and nonmatching + traffic, then enforce and verify backend receipt for the intended client. + Keep actual client addresses and deployment identities out of public docs. + +Required tests cover legacy-database migration; restart and scope retention; +null versus empty; invalid and oversized inputs; IPv4, IPv6 and mapped clients; +atomic writes and failed-batch cursor retention; preapproval before observation; +status changes; explicit scope removal; mixed-version rejection; decision +precedence; trusted bypass; observe versus strict listeners; shadow logging; and +two clients with the same fingerprint on opposite sides of the scope. Integration +checks must verify a rejected client never opens a backend connection. + +Before live enforcement, record the running revisions, shadow evidence and a +rollback that uses blocks or a scope-aware prior binary. Reverting to a binary +that ignores scope would broaden an approval and is not a safe rollback. + +## Library API + +`approval.New` validates and normalizes CIDRs. `Scope.Allows` accepts a +`netip.Addr`; a nil scope is unrestricted, while a non-nil empty scope is invalid. +Store `Entry.ApprovalRanges` persists the scope. `Store.ApplyDecisions` replaces +status, label and scope atomically and can persist a synchronization cursor in +the same transaction. This API treats nil scope as an explicit removal. + +Legacy `SetStatus` and `UpsertStatus` reject approving an already restricted row. +Blocking or resetting to pending clears its scope. Observation preserves scope. +The sync client rejects an entire invalid batch instead of skipping invalid +members, and stores cursors per control-plane URL and instance. Resetting the +fingerprint database also clears its persisted policy cursors. Trusted-range +callbacks remain a separate in-memory update; they are applied after decision +validation and before the database transaction. A failed database transaction +cannot partially change fingerprint approvals or advance their cursor. diff --git a/store/decisions.go b/store/decisions.go new file mode 100644 index 0000000..0e168bf --- /dev/null +++ b/store/decisions.go @@ -0,0 +1,65 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "github.com/kilo666mj/gatekit/approval" + "time" +) + +// Decision explicitly replaces status, label and approval scope together. +// Nil ApprovalRanges deliberately removes a prior restriction. +type Decision struct { + Fingerprint string `json:"fingerprint"` + Status Status `json:"status"` + Label string `json:"label,omitempty"` + ApprovalRanges *approval.Scope `json:"approval_ranges,omitempty"` +} + +func (d Decision) Validate() error { + if d.Fingerprint == "" || !d.Status.Valid() { + return fmt.Errorf("invalid fingerprint decision") + } + if d.ApprovalRanges != nil && d.Status != StatusApproved { + return fmt.Errorf("approval_ranges requires approved status") + } + return d.ApprovalRanges.Validate() +} + +// ApplyDecisions commits a validated batch and its optional sync cursor atomically. +// An invalid member or database failure changes neither decisions nor cursor. +func (s *Store) ApplyDecisions(decisions []Decision, cursorKey, cursor string) (err error) { + scopes := make([]any, len(decisions)) + for i, d := range decisions { + if err := d.Validate(); err != nil { + return err + } + if d.ApprovalRanges != nil { + b, err := json.Marshal(d.ApprovalRanges) + if err != nil { + return err + } + scopes[i] = string(b) + } + } + tx, err := s.db.BeginTx(context.Background(), nil) + if err != nil { + return err + } + defer rollbackTransaction(tx, &err) + now := encodeTime(time.Now()) + for i, d := range decisions { + if _, err := tx.Exec(`INSERT INTO fingerprints (fp,status,label,first_seen,last_seen,count,meta,approval_ranges) + VALUES (?,?,?,?,?,0,'{}',?) ON CONFLICT(fp) DO UPDATE SET status=excluded.status, + label=excluded.label, approval_ranges=excluded.approval_ranges`, d.Fingerprint, d.Status, d.Label, now, now, scopes[i]); err != nil { + return err + } + } + if cursorKey != "" && cursor != "" { + if _, err := tx.Exec(`INSERT INTO meta(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, cursorKey, cursor); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/store/decisions_test.go b/store/decisions_test.go new file mode 100644 index 0000000..0e57490 --- /dev/null +++ b/store/decisions_test.go @@ -0,0 +1,121 @@ +package store + +import ( + "database/sql" + "github.com/kilo666mj/gatekit/approval" + "net/netip" + "path/filepath" + "testing" +) + +func TestScopedDecisionPersistsAndLegacySettersCannotBroaden(t *testing.T) { + s := openTest(t) + scope, err := approval.New([]string{"192.0.2.0/24"}) + if err != nil { + t.Fatal(err) + } + d := Decision{Fingerprint: "shared", Status: StatusApproved, Label: "client", ApprovalRanges: scope} + if err := s.ApplyDecisions([]Decision{d}, "cursor", "1"); err != nil { + t.Fatal(err) + } + if err := s.SetStatus("shared", StatusApproved); err == nil { + t.Fatal("SetStatus broadened scope") + } + if err := s.UpsertStatus("shared", StatusApproved, "new"); err == nil { + t.Fatal("UpsertStatus broadened scope") + } + e, err := s.Observe(Observation{Fingerprint: "shared", IP: "198.51.100.1"}, true) + if err != nil { + t.Fatal(err) + } + if e.ApprovalRanges == nil || e.ApprovalRanges.Allows(netip.MustParseAddr("198.51.100.1")) { + t.Fatal("observation lost scope") + } + reopened, err := Open(Options{Path: s.Path()}) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() + e, err = reopened.Get("shared") + if err != nil || e.ApprovalRanges == nil { + t.Fatalf("restart: %+v %v", e, err) + } + if err := s.SetStatus("shared", StatusBlocked); err != nil { + t.Fatal(err) + } + e, err = s.Get("shared") + if err != nil || e.ApprovalRanges != nil { + t.Fatalf("block scope: %+v %v", e, err) + } + if err := s.ApplyDecisions([]Decision{d}, "", ""); err != nil { + t.Fatal(err) + } + d.ApprovalRanges = nil + if err := s.ApplyDecisions([]Decision{d}, "", ""); err != nil { + t.Fatal(err) + } + e, err = s.Get("shared") + if err != nil || e.ApprovalRanges != nil { + t.Fatalf("explicit removal: %+v %v", e, err) + } +} +func TestDecisionBatchAndCursorRollback(t *testing.T) { + s := openTest(t) + if err := s.ApplyDecisions([]Decision{{Fingerprint: "one", Status: StatusBlocked}}, "cursor", "old"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`CREATE TRIGGER fail_second BEFORE INSERT ON fingerprints WHEN NEW.fp='two' BEGIN SELECT RAISE(ABORT,'test failure'); END`); err != nil { + t.Fatal(err) + } + err := s.ApplyDecisions([]Decision{{Fingerprint: "one", Status: StatusApproved}, {Fingerprint: "two", Status: StatusApproved}}, "cursor", "new") + if err == nil { + t.Fatal("expected database failure") + } + e, err := s.Get("one") + if err != nil || e.Status != StatusBlocked { + t.Fatalf("partial policy: %+v %v", e, err) + } + cursor, err := s.GetMeta("cursor") + if err != nil || cursor != "old" { + t.Fatalf("cursor: %q %v", cursor, err) + } +} +func TestLegacyScopeMigrationAndCorruption(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE fingerprints(fp TEXT PRIMARY KEY,status TEXT NOT NULL,label TEXT NOT NULL DEFAULT '',first_seen TEXT NOT NULL,last_seen TEXT NOT NULL,count INTEGER NOT NULL DEFAULT 0,meta TEXT NOT NULL DEFAULT '{}'); INSERT INTO fingerprints(fp,status,first_seen,last_seen) VALUES('legacy','approved','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + s, err := Open(Options{Path: path}) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := s.Close(); err != nil { + t.Error(err) + } + }() + e, err := s.Get("legacy") + if err != nil || e.Status != StatusApproved || e.ApprovalRanges != nil { + t.Fatalf("legacy changed: %+v %v", e, err) + } + for _, bad := range []string{"[]", "null", "broken"} { + if _, err := s.db.Exec(`UPDATE fingerprints SET approval_ranges=? WHERE fp='legacy'`, bad); err != nil { + t.Fatal(err) + } + if _, err := s.Get("legacy"); err == nil { + t.Fatalf("accepted corrupt scope %s", bad) + } + } +} diff --git a/store/store.go b/store/store.go index 32258d6..428845d 100644 --- a/store/store.go +++ b/store/store.go @@ -21,6 +21,8 @@ import ( "strings" "time" + "github.com/kilo666mj/gatekit/approval" + _ "modernc.org/sqlite" ) @@ -44,16 +46,17 @@ func (s Status) Valid() bool { // Entry is a stored fingerprint and everything observed about it. type Entry struct { - Fingerprint string `json:"fingerprint"` - Status Status `json:"status"` - Label string `json:"label,omitempty"` - FirstSeen Time `json:"first_seen"` - LastSeen Time `json:"last_seen"` - Count int `json:"count"` - IPs []string `json:"ips,omitempty"` - Ports []int `json:"ports,omitempty"` - Sightings []Sighting `json:"sightings,omitempty"` - Meta map[string]any `json:"meta,omitempty"` + ApprovalRanges *approval.Scope `json:"approval_ranges,omitempty"` + Fingerprint string `json:"fingerprint"` + Status Status `json:"status"` + Label string `json:"label,omitempty"` + FirstSeen Time `json:"first_seen"` + LastSeen Time `json:"last_seen"` + Count int `json:"count"` + IPs []string `json:"ips,omitempty"` + Ports []int `json:"ports,omitempty"` + Sightings []Sighting `json:"sightings,omitempty"` + Meta map[string]any `json:"meta,omitempty"` } // Sighting is the most recent observation of one fingerprint from one source @@ -224,6 +227,7 @@ func (s *Store) init() error { for _, column := range []struct{ name, def string }{ {"count", "INTEGER NOT NULL DEFAULT 0"}, {"meta", "TEXT NOT NULL DEFAULT '{}'"}, + {"approval_ranges", "TEXT"}, } { if err := s.addColumnIfMissing(ctx, "fingerprints", column.name, column.def); err != nil { return err @@ -264,7 +268,7 @@ func (s *Store) hasColumn(ctx context.Context, table, column string) (_ bool, er return false, rows.Err() } -const entryColumns = `fp, status, label, first_seen, last_seen, count, meta` +const entryColumns = `fp, status, label, first_seen, last_seen, count, meta, approval_ranges` type scanner interface { Scan(dest ...any) error @@ -273,10 +277,22 @@ type scanner interface { // scanEntry decodes entryColumns (without IPs/ports) from a row. func scanEntry(sc scanner) (Entry, error) { var firstSeen, lastSeen, metaJSON string + var ranges sql.NullString var e Entry - if err := sc.Scan(&e.Fingerprint, &e.Status, &e.Label, &firstSeen, &lastSeen, &e.Count, &metaJSON); err != nil { + if err := sc.Scan(&e.Fingerprint, &e.Status, &e.Label, &firstSeen, &lastSeen, &e.Count, &metaJSON, &ranges); err != nil { return Entry{}, err } + if ranges.Valid { + if err := json.Unmarshal([]byte(ranges.String), &e.ApprovalRanges); err != nil { + return Entry{}, fmt.Errorf("decode approval_ranges: %w", err) + } + if e.ApprovalRanges == nil { + return Entry{}, errors.New("non-null approval_ranges must contain CIDRs") + } + if e.Status != StatusApproved { + return Entry{}, errors.New("scope on non-approved entry") + } + } parsedFirstSeen, err := decodeTime(firstSeen) if err != nil { return Entry{}, fmt.Errorf("decode first_seen: %w", err) @@ -476,7 +492,7 @@ func (s *Store) SetStatus(fp string, status Status) error { if !status.Valid() { return fmt.Errorf("invalid status %q", status) } - res, err := s.db.Exec(`UPDATE fingerprints SET status = ? WHERE fp = ?`, status, fp) + res, err := s.db.Exec(`UPDATE fingerprints SET status = ?, approval_ranges = NULL WHERE fp = ? AND (approval_ranges IS NULL OR ? != 'approved')`, status, fp, status) if err != nil { return err } @@ -491,12 +507,16 @@ func (s *Store) UpsertStatus(fp string, status Status, label string) error { return fmt.Errorf("invalid status %q", status) } now := encodeTime(time.Now()) - _, err := s.db.Exec(` + res, err := s.db.Exec(` INSERT INTO fingerprints (fp, status, label, first_seen, last_seen, count, meta) VALUES (?, ?, ?, ?, ?, 0, '{}') - ON CONFLICT(fp) DO UPDATE SET status = excluded.status, label = excluded.label`, + ON CONFLICT(fp) DO UPDATE SET status = excluded.status, label = excluded.label, approval_ranges = NULL + WHERE fingerprints.approval_ranges IS NULL OR excluded.status != 'approved'`, fp, status, label, now, now) - return err + if err != nil { + return err + } + return requireAffected(res, fp) } // SetLabel updates only the label of an existing fingerprint. @@ -636,12 +656,25 @@ func (s *Store) SetMeta(key, value string) error { // ResetFingerprints deletes every fingerprint row. Used when a gate's // fingerprint method changes and the existing keyspace is invalidated. -func (s *Store) ResetFingerprints() (int64, error) { - res, err := s.db.Exec(`DELETE FROM fingerprints`) +func (s *Store) ResetFingerprints() (_ int64, err error) { + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer rollbackTransaction(tx, &err) + res, err := tx.Exec(`DELETE FROM fingerprints`) if err != nil { return 0, err } - return res.RowsAffected() + count, err := res.RowsAffected() + if err != nil { + return 0, err + } + // A cleared store must fetch the full policy rather than reuse its old cursor. + if _, err := tx.Exec(`DELETE FROM meta WHERE key GLOB 'gatehub_cursor:*'`); err != nil { + return 0, err + } + return count, tx.Commit() } // MetaFingerprintMethod is the meta key recording which fingerprint method the