Files

228 lines
8.0 KiB
Go
Raw Permalink Normal View History

package webapp
import (
"fmt"
"strings"
"time"
"unicode/utf8"
)
// MetaStore is the hub's metadata persistence, split into one typed repository
// per entity. It holds ONLY the control plane — accounts, tokens, projects,
// orgs, invites, shares, devices. File content and the append-only journals
// live in the object store and never touch this; ephemeral state (one-time
// login and device codes, rate-limit buckets) stays in memory.
//
// A deployment chooses the backend: `file` (JSON on disk, the zero-dependency
// default) or `sql` (SQLite locally, Postgres/Supabase in production). The
// service structs (BuiltinAuth, OrgDB, …) keep their in-memory maps, mutexes,
// and business logic and persist each change through these repos — so reads
// stay in memory and writes are a single record apiece, which every backend
// implements as one real row.
type MetaStore interface {
Accounts() AccountRepo
Projects() ProjectRepo
Orgs() OrgRepo
Shares() ShareRepo
Devices() DeviceRepo
Reads() ReadRepo
SessionReads() SessionReadRepo
Close() error
}
// AccountRepo persists accounts, device tokens, and the (singleton) signup
// policy. Load returns everything at open; every other method is one record.
type AccountRepo interface {
Load() (users []*authUser, tokens []authToken, policy *authPolicy, err error)
PutAccount(u *authUser) error
DeleteAccount(id string) error
PutToken(t authToken) error
DeleteToken(hash string) error
PutPolicy(p authPolicy) error
}
type ProjectRepo interface {
Load() ([]Project, error)
Put(p Project) error
Delete(id string) error
}
type OrgRepo interface {
Load() (orgs []Org, invites []OrgInvite, err error)
PutOrg(o Org) error
DeleteOrg(id string) error
PutInvite(i OrgInvite) error
DeleteInvite(token string) error
}
type ShareRepo interface {
Load() ([]Share, error)
Put(s Share) error
Delete(token string) error
}
type DeviceRepo interface {
Load() ([]DeviceInfo, error)
Put(d DeviceInfo) error
// Delete removes one account's row for one device id. Rows are keyed by
// (account, id), and this is how an offboarded account's hub-wide claim on
// a machine is released — see DeviceRegistry.Release.
Delete(user, id string) error
}
// ReadRepo persists read-telemetry buckets (see ReadStat). Unlike the other
// repos it is batch-oriented: reads are telemetry, and the ledger flushes many
// dirty buckets at once — one file rewrite / one SQL transaction per flush,
// not one write per bucket.
type ReadRepo interface {
Load() ([]ReadStat, error)
PutBatch(stats []ReadStat) error // upsert by (project, path, day, kind, actor)
DeleteBatch(keys []ReadStatKey) error
}
// SessionReadRepo persists which paths one agent session read (see
// SessionRead). Deliberately its OWN repo rather than a session column on
// read_stats: ReadLedger loads every read_stats row into one map at boot and
// ReadLedger.Heat linearly scans that whole map on every heat request,
// hub-wide — so multiplying its row count by session cardinality would slow
// the Dashboard for projects that never ran an agent. These rows never enter
// that map; they are queried by primary key and pruned by date.
type SessionReadRepo interface {
PutBatch(reads []SessionRead) error // upsert by (project, session, device, path)
ListBySession(project, session, device string) ([]SessionRead, error)
PruneBefore(t time.Time) error
}
// ---- cheap change detection ---------------------------------------------
// Versioned is the optional "has anything moved?" check on a repository: a
// token that changes whenever anything the repo stores changes.
//
// Every registry re-reads its whole store on every authorization read (see
// ProjectDB.refresh) — that is a correctness floor, not a cache, and it stays.
// This is the same read made cheap: one os.Stat, or one primary-key lookup,
// instead of a full JSON parse or nine unfiltered SELECTs on every
// authenticated request. It is NOT a TTL: a token that moved is always
// followed by the full re-read, so the staleness window rounds 12-14 closed
// stays closed.
//
// A repo that cannot answer — no implementation, or an error — is treated as
// changed, so the fallback is exactly the unconditional re-read that was
// always there. An implementation must never return an empty token.
type Versioned interface {
Version() (string, error)
}
// versionGate is the per-registry half of that check. It remembers the token
// of the last SUCCESSFUL load, so a load that failed never leaves the registry
// marked fresh. Callers hold the registry's own mutex — refresh() already does.
type versionGate struct {
token string
valid bool
}
// stale reports whether repo may have changed since the last successful load,
// and returns the token to record once that load succeeds.
func (g *versionGate) stale(repo any) (token string, stale bool) {
v, ok := repo.(Versioned)
if !ok {
return "", true
}
cur, err := v.Version()
if err != nil || cur == "" {
return "", true // can't tell → re-read, and record nothing
}
if g.valid && cur == g.token {
return cur, false
}
return cur, true
}
// fresh records the token of a load that succeeded.
func (g *versionGate) fresh(token string) {
if token != "" {
g.token, g.valid = token, true
}
}
// ---- what a metadata store will hold ------------------------------------
// storable refuses text that no metadata backend can hold faithfully, so all
// three agree on which requests succeed.
//
// The three disagreed on eighteen inputs. A NUL cannot go in a Postgres text
// column at all (SQLSTATE 22021), while sqlite keeps it and the file backend
// keeps it. Invalid UTF-8 is worse than a disagreement on the file backend —
// the default: encoding/json substitutes U+FFFD per bad byte and reports
// SUCCESS, so the running hub and its database hold different records, nothing
// is logged, and two inputs that differ in memory fold onto one key on disk.
//
// Refusing is the decision rather than storing verbatim, for two reasons. It
// is what the doors already enforce (printableOnly, hasControlChars,
// journal.SafePath), so there is one rule instead of three. And it means a hub
// cannot change what it accepts by changing its database — the property that
// let row 14 look clean for seven rounds while the backends diverged.
//
// It replaces the assertion in the retired TestSec_DB_NULBytesDoNotTruncateRecords
// ("a NUL must round-trip verbatim"), which Postgres cannot implement without
// moving this whole layer to bytea. See TestSec_DB_EveryBackendAgreesWhichTextIsStorable.
func storable(vals ...string) error {
for _, v := range vals {
if strings.IndexByte(v, 0) >= 0 {
return fmt.Errorf("metadata text may not contain a NUL byte: %q", v)
}
if !utf8.ValidString(v) {
return fmt.Errorf("metadata text must be valid UTF-8: %q", v)
}
}
return nil
}
// storableMap checks a map's keys and values — the grant and membership maps,
// where the KEY is the account an authorization decision keys on.
func storableMap(m map[string]string) error {
for k, v := range m {
if err := storable(k, v); err != nil {
return err
}
}
return nil
}
func checkAccount(u *authUser) error {
return storable(u.ID, u.Email, u.Name, u.Pass, u.Status)
}
func checkToken(t authToken) error { return storable(t.Hash, t.User, t.Device) }
func checkProject(p Project) error {
if err := storable(p.ID, p.Name, p.Org, p.Description, p.Icon,
p.Creator, p.Template, p.Default); err != nil {
return err
}
return storableMap(p.Perms)
}
func checkOrg(o Org) error {
if err := storable(o.ID, o.Name); err != nil {
return err
}
return storableMap(o.Members)
}
func checkInvite(i OrgInvite) error { return storable(i.Token, i.Org, i.Creator) }
func checkShare(s Share) error { return storable(s.Token, s.Project, s.Path, s.Creator) }
func checkDevice(d DeviceInfo) error {
return storable(d.ID, d.Name, d.OS, d.User, d.IP)
}
func checkReadStat(s ReadStat) error {
return storable(s.Project, s.Path, s.Day, s.Kind, s.Actor)
}
func checkSessionRead(s SessionRead) error {
return storable(s.Project, s.Session, s.Path, s.Device)
}