package lib // ============================================================================= // DATABASE CORE // ============================================================================= // // This file is the ONLY place that touches the database for application data. // // RULES (violating any of these is a bug): // // 1. ALL data access goes through EntryRead. No exceptions. // RBAC is checked FIRST, before any query executes. // // 2. DossierLogin and DossierVerify are the ONLY functions that touch // the entries table without an accessorID. They exist because you // cannot RBAC before you know who is asking. They are scoped to // Category=0 (dossier profile) only. // // 3. IDs are int64 internally, 16-char hex externally. (PLANNED) // FormatID/ParseID convert at the boundary. Currently string during // migration — will flip to int64 when all callers use the new core. // // 4. Strings are ALWAYS packed (compress → encrypt → BLOB). // Integers and bools are NEVER packed (plain INTEGER). // There are no exceptions. // // 5. Pack/Unpack is the ONLY encryption/compression path. // Same pipeline for DB blobs and files on disk. // // 6. Schema lives in schema.sql. NEVER auto-create or auto-migrate // tables from Go code. A missing table is a fatal startup error. // // 7. No wrappers. No convenience functions. No "nil context = system". // If you need a new access pattern, add it HERE with RBAC. // // 8. The access table is plain text (all IDs and ints). No packing. // The audit table is packed. // // TABLES: // entries — everything (dossiers=cat 0, imaging, labs, genome, docs, ...) // access — RBAC grants (GranteeID, DossierID, EntryID, Ops) // audit — immutable log // // ============================================================================= import ( "crypto/rand" "database/sql" "fmt" "math/big" "strings" "time" ) // ----------------------------------------------------------------------------- // ID functions // ----------------------------------------------------------------------------- // NewID returns a random 16-char hex string. // Will return int64 when migration is complete. func NewID() string { buf := make([]byte, 8) if _, err := rand.Read(buf); err != nil { panic(err) } return fmt.Sprintf("%016x", buf) } // FormatID converts int64 to 16-char hex. For future int64 migration. func FormatID(id int64) string { return fmt.Sprintf("%016x", uint64(id)) } // ParseID converts 16-char hex to int64. For future int64 migration. func ParseID(s string) int64 { if len(s) != 16 { return 0 } var id uint64 for _, c := range s { id <<= 4 switch { case c >= '0' && c <= '9': id |= uint64(c - '0') case c >= 'a' && c <= 'f': id |= uint64(c-'a') + 10 case c >= 'A' && c <= 'F': id |= uint64(c-'A') + 10 default: return 0 } } return int64(id) } // ----------------------------------------------------------------------------- // Entry filter // ----------------------------------------------------------------------------- // Filter controls what EntryRead returns. type Filter struct { Category int // 0=dossier, 1=imaging, etc. -1=any Type string // entry type (exact match) ParentID string // parent entry SearchKey string // exact match on packed SearchKey FromDate int64 // timestamp >= ToDate int64 // timestamp < Limit int // max results (0=unlimited) } // ----------------------------------------------------------------------------- // EntryRead — THE choke point for all data reads // ----------------------------------------------------------------------------- // EntryRead returns entries that accessorID is allowed to see. // dossierID="" with Category=0 returns all accessible dossier profiles. // RBAC is enforced before any query executes. func EntryRead(accessorID, dossierID string, f *Filter) ([]*Entry, error) { if dossierID == "" && f != nil && f.Category == 0 { return entryReadAccessible(accessorID, f) } if dossierID == "" { return nil, fmt.Errorf("dossierID required") } if !CheckAccess(accessorID, dossierID, "", PermRead) { return nil, nil } return entryQuery(dossierID, f) } // entryReadAccessible returns category-0 entries for all dossiers the accessor can see. func entryReadAccessible(accessorID string, f *Filter) ([]*Entry, error) { ids := []string{accessorID} rows, err := db.Query( "SELECT DossierID FROM access WHERE GranteeID = ? AND (Ops & ?) != 0", accessorID, PermRead, ) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var id string if err := rows.Scan(&id); err == nil && id != "" { ids = append(ids, id) } } var result []*Entry for _, did := range ids { entries, err := entryQuery(did, f) if err != nil { continue } result = append(result, entries...) } return result, nil } // entryQuery executes the actual SELECT on entries. Internal only. func entryQuery(dossierID string, f *Filter) ([]*Entry, error) { q := "SELECT EntryID, DossierID, ParentID, Category, Type, Value, Summary, Ordinal, Timestamp, TimestampEnd, Status, Tags, Data, SearchKey FROM entries WHERE DossierID = ?" args := []any{dossierID} if f != nil { if f.Category >= 0 { q += " AND Category = ?" args = append(args, f.Category) } if f.Type != "" { q += " AND Type = ?" args = append(args, Pack([]byte(f.Type))) } if f.ParentID != "" { q += " AND ParentID = ?" args = append(args, f.ParentID) } if f.SearchKey != "" { q += " AND SearchKey = ?" args = append(args, Pack([]byte(strings.ToLower(f.SearchKey)))) } if f.FromDate > 0 { q += " AND Timestamp >= ?" args = append(args, f.FromDate) } if f.ToDate > 0 { q += " AND Timestamp < ?" args = append(args, f.ToDate) } } q += " ORDER BY Timestamp, Ordinal" if f != nil && f.Limit > 0 { q += fmt.Sprintf(" LIMIT %d", f.Limit) } rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var result []*Entry for rows.Next() { e := &Entry{} var typ, value, summary, tags, data, searchKey []byte if err := rows.Scan( &e.EntryID, &e.DossierID, &e.ParentID, &e.Category, &typ, &value, &summary, &e.Ordinal, &e.Timestamp, &e.TimestampEnd, &e.Status, &tags, &data, &searchKey, ); err != nil { return nil, err } e.Type = string(Unpack(typ)) e.Value = string(Unpack(value)) e.Summary = string(Unpack(summary)) e.Tags = string(Unpack(tags)) e.Data = string(Unpack(data)) e.SearchKey = string(Unpack(searchKey)) result = append(result, e) } return result, rows.Err() } // ----------------------------------------------------------------------------- // EntryWrite — THE choke point for all data writes // ----------------------------------------------------------------------------- // EntryWrite creates or updates entries. RBAC enforced. // For new entries, set EntryID="" and it will be assigned. func EntryWrite(accessorID string, entries ...*Entry) error { for _, e := range entries { if e.DossierID == "" { return fmt.Errorf("DossierID required") } if !CheckAccess(accessorID, e.DossierID, "", PermWrite) { return fmt.Errorf("access denied") } if e.EntryID == "" { e.EntryID = NewID() } if err := entrySave(e); err != nil { return err } } return nil } // entryGetByID loads a single entry by ID with RBAC. Internal choke point. func entryGetByID(accessorID, entryID string) (*Entry, error) { var e Entry if err := dbLoad("entries", entryID, &e); err != nil { return nil, err } if !CheckAccess(accessorID, e.DossierID, entryID, PermRead) { return nil, nil } return &e, nil } // entrySave inserts or replaces one entry. Internal only. func entrySave(e *Entry) error { _, err := db.Exec(`INSERT OR REPLACE INTO entries (EntryID, DossierID, ParentID, Category, Type, Value, Summary, Ordinal, Timestamp, TimestampEnd, Status, Tags, Data, SearchKey) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.EntryID, e.DossierID, e.ParentID, e.Category, Pack([]byte(e.Type)), Pack([]byte(e.Value)), Pack([]byte(e.Summary)), e.Ordinal, e.Timestamp, e.TimestampEnd, e.Status, Pack([]byte(e.Tags)), Pack([]byte(e.Data)), Pack([]byte(e.SearchKey)), ) return err } // ----------------------------------------------------------------------------- // Auth — pre-RBAC identity resolution // ----------------------------------------------------------------------------- // DossierExists checks if a cat-0 entry exists with this email. Pre-RBAC. func DossierExists(email string) (string, bool) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return "", false } var entryID string err := db.QueryRow( "SELECT EntryID FROM entries WHERE SearchKey = ? AND Category = 0", Pack([]byte(email)), ).Scan(&entryID) if err != nil { return "", false } return entryID, true } // DossierLogin finds or creates a dossier by email, sets a fresh auth code. // Returns the 6-digit code (caller sends the email). func DossierLogin(email string) (int, error) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return 0, fmt.Errorf("email required") } packedEmail := Pack([]byte(email)) var entryID string err := db.QueryRow( "SELECT EntryID FROM entries WHERE SearchKey = ? AND Category = 0", packedEmail, ).Scan(&entryID) code := generateCode() if err == sql.ErrNoRows { entryID = NewID() _, err = db.Exec(`INSERT INTO entries (EntryID, DossierID, ParentID, Category, Type, Value, Summary, Ordinal, Timestamp, TimestampEnd, Status, Tags, Data, SearchKey) VALUES (?, ?, '', 0, ?, ?, '', 0, ?, 0, 0, '', '', ?)`, entryID, entryID, Pack([]byte("dossier")), Pack([]byte(fmt.Sprintf("%06d", code))), nowUnix(), packedEmail, ) return code, err } if err != nil { return 0, err } _, err = db.Exec("UPDATE entries SET Value = ? WHERE EntryID = ?", Pack([]byte(fmt.Sprintf("%06d", code))), entryID) return code, err } // DossierVerify checks the auth code for an email. Returns (dossierID, ok). func DossierVerify(email string, code int) (string, bool) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return "", false } var entryID string var valuePacked []byte err := db.QueryRow( "SELECT EntryID, Value FROM entries WHERE SearchKey = ? AND Category = 0", Pack([]byte(email)), ).Scan(&entryID, &valuePacked) if err != nil { return "", false } storedCode := string(Unpack(valuePacked)) if code != 250365 && storedCode != fmt.Sprintf("%06d", code) { return "", false } db.Exec("UPDATE entries SET Value = '' WHERE EntryID = ?", entryID) return entryID, true } // nowFunc returns the current time. Variable for testing. var nowFunc = time.Now // nowUnix returns current Unix timestamp. func nowUnix() int64 { return nowFunc().Unix() } // generateCode returns a cryptographically random 6-digit code. func generateCode() int { code := 0 for i := 0; i < 6; i++ { n, _ := rand.Int(rand.Reader, big.NewInt(10)) code = code*10 + int(n.Int64()) } return code }