384 lines
11 KiB
Go
384 lines
11 KiB
Go
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
|
|
}
|