package lib import ( "bytes" "encoding/json" "fmt" "image" "image/color" "image/png" "os" "path/filepath" "sort" "strings" "time" ) // ============================================================================ // V2 API - Clean data layer using Store // ============================================================================ // // RBAC ENFORCEMENT: // All data access functions take AccessContext as first parameter. // Permission checks happen HERE at the lowest level - there is NO WAY to bypass. // // Rules: // - ctx == nil or ctx.IsSystem → allow (for internal/system operations) // - ctx.AccessorID == dossierID → allow (owner) // - Otherwise → check access grants in database // // ============================================================================ // accessorIDFromContext extracts accessorID from AccessContext for RBAC checks // Returns SystemAccessorID for system context (explicit backdoor with audit trail) func accessorIDFromContext(ctx *AccessContext) string { if ctx == nil || ctx.IsSystem { return SystemAccessorID } return ctx.AccessorID } // --- ENTRY --- type EntryFilter struct { DossierID string Type string Value string SearchKey string FromDate int64 ToDate int64 Limit int } // EntryWrite creates/updates entries. Requires write permission on parent. func EntryWrite(ctx *AccessContext, entries ...*Entry) error { if len(entries) == 0 { return nil } // RBAC: Check write permission for each entry for _, e := range entries { if e.DossierID == "" { return fmt.Errorf("entry missing dossier_id") } // Check write on parent (or root if no parent) if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, e.Category, 'w'); err != nil { return err } } for _, e := range entries { if e.EntryID == "" { e.EntryID = NewID() } } if len(entries) == 1 { return dbSave("entries", entries[0]) } return dbSave("entries", entries) } // EntryRemove deletes entries. Requires delete permission. func EntryRemove(ctx *AccessContext, ids ...string) error { // RBAC: Check delete permission for each entry for _, id := range ids { e, err := entryGetRaw(id) if err != nil { continue // Entry doesn't exist, skip } if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'd'); err != nil { return err } } return deleteByIDs("entries", "entry_id", ids) } // EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root. func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error { // RBAC: Check delete permission on dossier root if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil { return err } var entries []*Entry if err := dbQuery("SELECT entry_id FROM entries WHERE dossier_id = ?", []any{dossierID}, &entries); err != nil { return err } for _, e := range entries { if err := dbDelete("entries", "entry_id", e.EntryID); err != nil { return err } } return nil } // EntryGet retrieves an entry. Requires read permission. func EntryGet(ctx *AccessContext, id string) (*Entry, error) { e, err := entryGetRaw(id) if err != nil { return nil, err } // RBAC: Check read permission if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil { return nil, err } return e, nil } // entryGetRaw retrieves an entry without permission check (internal use only) func entryGetRaw(id string) (*Entry, error) { e := &Entry{} return e, dbLoad("entries", id, e) } // EntryList retrieves entries. Requires read permission on parent/dossier. // accessorID: who is asking (SystemAccessorID = internal operations) // dossierID comes from f.DossierID func EntryList(accessorID string, parent string, category int, f *EntryFilter) ([]*Entry, error) { // RBAC: Determine dossier and check read permission dossierID := "" if f != nil { dossierID = f.DossierID } if dossierID == "" && parent != "" { // Get dossier from parent entry if p, err := entryGetRaw(parent); err == nil { dossierID = p.DossierID } } if dossierID != "" { if err := checkAccess(accessorID, dossierID, parent, category, 'r'); err != nil { return nil, err } } q := "SELECT * FROM entries WHERE 1=1" args := []any{} if category > 0 { q += " AND category = ?" args = append(args, category) } if parent == "" { q += " AND (parent_id IS NULL OR parent_id = '')" } else { q += " AND parent_id = ?" args = append(args, parent) } if f != nil { if f.DossierID != "" { q += " AND dossier_id = ?" args = append(args, f.DossierID) } if f.Type != "" { q += " AND type = ?" args = append(args, CryptoEncrypt(f.Type)) } if f.Value != "" { q += " AND value = ?" args = append(args, CryptoEncrypt(f.Value)) } if f.SearchKey != "" { q += " AND search_key = ?" args = append(args, CryptoEncrypt(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 ordinal" if f != nil && f.Limit > 0 { q += fmt.Sprintf(" LIMIT %d", f.Limit) } var result []*Entry err := dbQuery(q, args, &result) return result, err } // --- DOSSIER --- type DossierFilter struct { EmailHash string DateOfBirth string Limit int } // DossierWrite creates/updates dossiers. Requires manage permission (or nil ctx for system). func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error { if len(dossiers) == 0 { return nil } // RBAC: For existing dossiers, check manage permission for _, d := range dossiers { if d.DossierID != "" { // Update - need manage permission (unless creating own or system) if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 0, 'm'); err != nil { return err } } // New dossiers (no ID) are allowed - they'll get assigned an ID } for _, d := range dossiers { if d.DossierID == "" { d.DossierID = NewID() } // Format DOB to encrypted string if !d.DOB.IsZero() { d.DateOfBirth = d.DOB.Format("2006-01-02") } } if len(dossiers) == 1 { return dbSave("dossiers", dossiers[0]) } return dbSave("dossiers", dossiers) } // DossierRemove deletes dossiers. Requires manage permission. func DossierRemove(ctx *AccessContext, ids ...string) error { // RBAC: Check manage permission for each dossier for _, id := range ids { if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'm'); err != nil { return err } } return deleteByIDs("dossiers", "dossier_id", ids) } // DossierGet retrieves a dossier. Requires read permission. func DossierGet(ctx *AccessContext, id string) (*Dossier, error) { // RBAC: Check read permission if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'r'); err != nil { return nil, err } return dossierGetRaw(id) } // dossierGetRaw retrieves a dossier without permission check (internal use only) func dossierGetRaw(id string) (*Dossier, error) { d := &Dossier{} if err := dbLoad("dossiers", id, d); err != nil { return nil, err } // Parse DOB from encrypted string if d.DateOfBirth != "" { d.DOB, _ = time.Parse("2006-01-02", d.DateOfBirth) } return d, nil } // DossierList retrieves dossiers. System only (for security - lists all dossiers). func DossierList(ctx *AccessContext, f *DossierFilter) ([]*Dossier, error) { // RBAC: Only system context can list all dossiers if ctx != nil && !ctx.IsSystem { return nil, ErrAccessDenied } q := "SELECT * FROM dossiers WHERE 1=1" args := []any{} if f != nil { if f.EmailHash != "" { q += " AND email_hash = ?" args = append(args, f.EmailHash) } if f.DateOfBirth != "" { q += " AND date_of_birth = ?" args = append(args, CryptoEncrypt(f.DateOfBirth)) } if f.Limit > 0 { q += fmt.Sprintf(" LIMIT %d", f.Limit) } } var result []*Dossier err := dbQuery(q, args, &result) return result, err } // DossierGetByEmail retrieves a dossier by email. System only (auth flow). func DossierGetByEmail(ctx *AccessContext, email string) (*Dossier, error) { // RBAC: Only system context can lookup by email (for auth) if ctx != nil && !ctx.IsSystem { return nil, ErrAccessDenied } email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return nil, fmt.Errorf("empty email") } q := "SELECT * FROM dossiers WHERE email = ? LIMIT 1" var result []*Dossier if err := dbQuery(q, []any{CryptoEncrypt(email)}, &result); err != nil { return nil, err } if len(result) == 0 { return nil, fmt.Errorf("not found") } return result[0], nil } // DossierGetBySessionToken retrieves a dossier by session token. No RBAC (auth flow). func DossierGetBySessionToken(token string) *Dossier { if token == "" { return nil } q := "SELECT * FROM dossiers WHERE session_token = ? LIMIT 1" var result []*Dossier if err := dbQuery(q, []any{CryptoEncrypt(token)}, &result); err != nil { return nil } if len(result) == 0 { return nil } return result[0] } // --- LEGACY ACCESS (dossier_access table) --- // TODO: Migrate to new RBAC access table type AccessFilter struct { AccessorID string TargetID string Status *int } func AccessWrite(records ...*DossierAccess) error { if len(records) == 0 { return nil } for _, r := range records { if r.AccessID == "" { r.AccessID = NewID() } } if len(records) == 1 { return dbSave("dossier_access", records[0]) } return dbSave("dossier_access", records) } func AccessRemove(accessorID, targetID string) error { access, err := AccessGet(accessorID, targetID) if err != nil { return err } return dbDelete("dossier_access", "access_id", access.AccessID) } func AccessGet(accessorID, targetID string) (*DossierAccess, error) { q := "SELECT * FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ?" var result []*DossierAccess if err := dbQuery(q, []any{accessorID, targetID}, &result); err != nil { return nil, err } if len(result) == 0 { return nil, fmt.Errorf("not found") } return result[0], nil } func AccessList(f *AccessFilter) ([]*DossierAccess, error) { q := "SELECT * FROM dossier_access WHERE 1=1" args := []any{} if f != nil { if f.AccessorID != "" { q += " AND accessor_dossier_id = ?" args = append(args, f.AccessorID) } if f.TargetID != "" { q += " AND target_dossier_id = ?" args = append(args, f.TargetID) } if f.Status != nil { q += " AND status = ?" args = append(args, *f.Status) } } var result []*DossierAccess err := dbQuery(q, args, &result) return result, err } // --- AUDIT --- type AuditFilter struct { ActorID string TargetID string Action string FromDate int64 ToDate int64 Limit int } func AuditWrite(entries ...*AuditEntry) error { if len(entries) == 0 { return nil } for _, a := range entries { if a.AuditID == "" { a.AuditID = NewID() } } if len(entries) == 1 { return dbSave("audit", entries[0]) } return dbSave("audit", entries) } func AuditList(f *AuditFilter) ([]*AuditEntry, error) { q := "SELECT * FROM audit WHERE 1=1" args := []any{} if f != nil { if f.ActorID != "" { q += " AND actor1_id = ?" args = append(args, f.ActorID) } if f.TargetID != "" { q += " AND target_id = ?" args = append(args, f.TargetID) } if f.Action != "" { q += " AND action = ?" args = append(args, CryptoEncrypt(f.Action)) } 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 DESC" if f != nil && f.Limit > 0 { q += fmt.Sprintf(" LIMIT %d", f.Limit) } var result []*AuditEntry err := dbQuery(q, args, &result) return result, err } // --- PROMPT --- type TrackerFilter struct { DossierID string Category string Type string ActiveOnly bool Limit int } func TrackerWrite(trackers ...*Tracker) error { if len(trackers) == 0 { return nil } for _, p := range trackers { if p.TrackerID == "" { p.TrackerID = NewID() } } if len(trackers) == 1 { return dbSave("trackers", trackers[0]) } return dbSave("trackers", trackers) } func TrackerRemove(ids ...string) error { return deleteByIDs("trackers", "tracker_id", ids) } func TrackerList(f *TrackerFilter) ([]*Tracker, error) { q := "SELECT * FROM trackers WHERE 1=1" args := []any{} if f != nil { if f.DossierID != "" { q += " AND dossier_id = ?" args = append(args, f.DossierID) } if f.Category != "" { q += " AND category = ?" args = append(args, CryptoEncrypt(f.Category)) } if f.Type != "" { q += " AND type = ?" args = append(args, CryptoEncrypt(f.Type)) } if f.ActiveOnly { q += " AND active = 1 AND dismissed = 0" } } q += " ORDER BY active DESC, time_of_day, created_at" if f != nil && f.Limit > 0 { q += fmt.Sprintf(" LIMIT %d", f.Limit) } var result []*Tracker err := dbQuery(q, args, &result) return result, err } // --- IMAGE --- type ImageOpts struct { WC, WW float64 // window center/width (0 = defaults) Zoom float64 // unused PanX, PanY float64 // unused } // ImageGet retrieves an image. Requires read permission. func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) { e, err := entryGetRaw(id) if err != nil { return nil, err } // RBAC: Check read permission if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil { return nil, err } var data struct { WindowCenter float64 `json:"window_center"` WindowWidth float64 `json:"window_width"` PixelMin int `json:"pixel_min"` PixelMax int `json:"pixel_max"` RescaleSlope float64 `json:"rescale_slope"` RescaleIntercept float64 `json:"rescale_intercept"` } json.Unmarshal([]byte(e.Data), &data) slope := data.RescaleSlope if slope == 0 { slope = 1 } center, width := data.WindowCenter, data.WindowWidth if data.RescaleIntercept != 0 { center = (center - data.RescaleIntercept) / slope width = width / slope } if center == 0 && width == 0 { center = float64(data.PixelMin+data.PixelMax) / 2 width = float64(data.PixelMax - data.PixelMin) if width == 0 { width = 1 } } if opts != nil { if opts.WC != 0 { center = opts.WC } if opts.WW != 0 { width = opts.WW } } dec, err := objectReadRaw(e.DossierID, id) if err != nil { return nil, err } img, err := png.Decode(bytes.NewReader(dec)) if err != nil { return nil, err } g16, ok := img.(*image.Gray16) if !ok { return nil, fmt.Errorf("not 16-bit grayscale") } low, high := center-width/2, center+width/2 lut := make([]uint8, 65536) for i := range lut { if float64(i) <= low { lut[i] = 0 } else if float64(i) >= high { lut[i] = 255 } else { lut[i] = uint8((float64(i) - low) * 255 / width) } } bounds := g16.Bounds() out := image.NewGray(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { out.SetGray(x, y, color.Gray{Y: lut[g16.Gray16At(x, y).Y]}) } } var buf bytes.Buffer png.Encode(&buf, out) return buf.Bytes(), nil } // --- OBJECT STORAGE --- // ObjectWrite encrypts and writes data to the object store. Requires write permission. func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error { // RBAC: Check write permission if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'w'); err != nil { return err } path := ObjectPath(dossierID, entryID) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } encrypted := CryptoEncryptBytes(data) return os.WriteFile(path, encrypted, 0644) } // ObjectRead reads and decrypts data from the object store. Requires read permission. func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) { // RBAC: Check read permission if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'r'); err != nil { return nil, err } raw, err := os.ReadFile(ObjectPath(dossierID, entryID)) if err != nil { return nil, err } return CryptoDecryptBytes(raw) } // objectReadRaw reads object without permission check (internal use only, e.g. ImageGet) func objectReadRaw(dossierID, entryID string) ([]byte, error) { raw, err := os.ReadFile(ObjectPath(dossierID, entryID)) if err != nil { return nil, err } return CryptoDecryptBytes(raw) } // ObjectRemove deletes an object from the store. Requires delete permission. func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error { // RBAC: Check delete permission if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'd'); err != nil { return err } return os.Remove(ObjectPath(dossierID, entryID)) } // ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission. func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error { // RBAC: Check delete permission on dossier root if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil { return err } return os.RemoveAll(filepath.Join(ObjectDir, dossierID)) } // DossierListAccessible is now in access.go with RBAC enforcement // DossierGetByEmailHash returns a dossier by email_hash. System only. func DossierGetByEmailHash(ctx *AccessContext, emailHash string) (*Dossier, error) { dossiers, err := DossierList(ctx, &DossierFilter{EmailHash: emailHash, Limit: 1}) if err != nil { return nil, err } if len(dossiers) == 0 { return nil, fmt.Errorf("not found") } return dossiers[0], nil } // DossierGetFirst returns the first dossier (for dev/test purposes only). System only. func DossierGetFirst(ctx *AccessContext) (*Dossier, error) { dossiers, err := DossierList(ctx, &DossierFilter{Limit: 1}) if err != nil { return nil, err } if len(dossiers) == 0 { return nil, fmt.Errorf("no dossiers") } return dossiers[0], nil } // AccessListByTargetWithNames returns access records with accessor names joined from dossiers func AccessListByTargetWithNames(targetID string) ([]map[string]interface{}, error) { // Get access records via store.go accessList, err := AccessList(&AccessFilter{TargetID: targetID}) if err != nil { return nil, err } var result []map[string]interface{} for _, a := range accessList { name := "" if d, err := DossierGet(nil, a.AccessorDossierID); err == nil && d != nil { name = d.Name } result = append(result, map[string]interface{}{ "accessor_id": a.AccessorDossierID, "name": name, "relation": a.Relation, "is_care_receiver": a.IsCareReceiver, "can_edit": a.CanEdit, }) } return result, nil } // TrackerDistinctTypes returns distinct category/type pairs for a dossier's active trackers func TrackerDistinctTypes(dossierID string) (map[string][]string, error) { var trackers []*Tracker if err := dbQuery("SELECT * FROM trackers WHERE dossier_id = ? AND active = 1", []any{dossierID}, &trackers); err != nil { return nil, err } // Extract distinct category/type pairs seen := make(map[string]bool) result := make(map[string][]string) for _, p := range trackers { key := p.Category + "|" + p.Type if !seen[key] && p.Category != "" && p.Type != "" { seen[key] = true result[p.Category] = append(result[p.Category], p.Type) } } return result, nil } // --- RBAC PERMISSIONS --- type PermissionFilter struct { DossierID string // whose data GranteeID string // who has access EntryID string // specific entry Role string // filter by role name } // AccessWrite saves one or more access grants func AccessGrantWrite(grants ...*Access) error { if len(grants) == 0 { return nil } for _, a := range grants { if a.AccessID == "" { a.AccessID = NewID() } if a.CreatedAt == 0 { a.CreatedAt = time.Now().Unix() } } if len(grants) == 1 { return dbSave("access", grants[0]) } return dbSave("access", grants) } // AccessGrantRemove removes access grants by ID func AccessGrantRemove(ids ...string) error { for _, id := range ids { if err := dbDelete("access", "access_id", id); err != nil { return err } } return nil } // MigrateOldAccess converts dossier_access entries to access grants (one-time migration) func MigrateOldAccess() int { type oldAccess struct { AccessorID string `db:"accessor_dossier_id"` TargetID string `db:"target_dossier_id"` CanEdit int `db:"can_edit"` } var entries []oldAccess err := dbQuery("SELECT accessor_dossier_id, target_dossier_id, can_edit FROM dossier_access WHERE status = 1", nil, &entries) if err != nil { return 0 } migrated := 0 for _, e := range entries { // Skip self-references (owner access is automatic) if e.AccessorID == e.TargetID { continue } // Skip if grant already exists existing, _ := AccessGrantList(&PermissionFilter{DossierID: e.TargetID, GranteeID: e.AccessorID}) if len(existing) > 0 { continue } // Create root-level grant ops := "r" if e.CanEdit == 1 { ops = "rwdm" } AccessGrantWrite(&Access{ DossierID: e.TargetID, GranteeID: e.AccessorID, EntryID: "", Role: "Migrated", Ops: ops, }) migrated++ } return migrated } // MigrateStudiesToCategoryRoot moves orphan studies (parent_id="") under their // imaging category_root entry. Idempotent — skips studies already parented. func MigrateStudiesToCategoryRoot() int { // Find all imaging entries with empty parent_id, filter to studies in Go var all []*Entry err := dbQuery( "SELECT * FROM entries WHERE category = ? AND (parent_id IS NULL OR parent_id = '')", []any{CategoryImaging}, &all) if err != nil { return 0 } var studies []*Entry for _, e := range all { if e.Type == "study" { studies = append(studies, e) } } if len(studies) == 0 { return 0 } migrated := 0 catRoots := map[string]string{} // dossier_id → category_root entry_id for _, s := range studies { rootID, ok := catRoots[s.DossierID] if !ok { rootID, err = EnsureCategoryRoot(s.DossierID, CategoryImaging) if err != nil { continue } catRoots[s.DossierID] = rootID } s.ParentID = rootID if err := dbSave("entries", s); err == nil { migrated++ } } return migrated } // AccessGrantGet retrieves a single access grant by ID func AccessGrantGet(id string) (*Access, error) { a := &Access{} return a, dbLoad("access", id, a) } // AccessGrantList retrieves access grants with optional filtering func AccessGrantList(f *PermissionFilter) ([]*Access, error) { q := "SELECT * FROM access WHERE 1=1" args := []any{} if f != nil { if f.DossierID != "" { q += " AND dossier_id = ?" args = append(args, f.DossierID) } if f.GranteeID != "" { q += " AND grantee_id = ?" args = append(args, f.GranteeID) } if f.EntryID != "" { q += " AND entry_id = ?" args = append(args, f.EntryID) } if f.Role != "" { q += " AND role = ?" args = append(args, CryptoEncrypt(f.Role)) } } q += " ORDER BY created_at DESC" var result []*Access err := dbQuery(q, args, &result) return result, err } // AccessGrantsForGrantee returns all grants for a specific grantee, grouped by dossier func AccessGrantsForGrantee(granteeID string) (map[string][]*Access, error) { grants, err := AccessGrantList(&PermissionFilter{GranteeID: granteeID}) if err != nil { return nil, err } result := make(map[string][]*Access) for _, g := range grants { result[g.DossierID] = append(result[g.DossierID], g) } return result, nil } // AccessRoleTemplates returns role templates (grantee_id is null) for a dossier // Pass empty dossierID for system-wide templates func AccessRoleTemplates(dossierID string) ([]*Access, error) { q := "SELECT * FROM access WHERE grantee_id IS NULL OR grantee_id = ''" args := []any{} if dossierID != "" { q += " AND (dossier_id = ? OR dossier_id IS NULL OR dossier_id = '')" args = append(args, dossierID) } else { q += " AND (dossier_id IS NULL OR dossier_id = '')" } q += " ORDER BY role, entry_id" var result []*Access err := dbQuery(q, args, &result) return result, err } // AccessGrantRole copies role template entries for a specific grantee func AccessGrantRole(dossierID, granteeID, role string) error { // Get template entries for this role templates, err := AccessRoleTemplates(dossierID) if err != nil { return err } var grants []*Access for _, t := range templates { if t.Role != role { continue } grants = append(grants, &Access{ DossierID: dossierID, GranteeID: granteeID, EntryID: t.EntryID, Role: role, Ops: t.Ops, }) } if len(grants) == 0 { // No template found, create default read-only grant grants = append(grants, &Access{ DossierID: dossierID, GranteeID: granteeID, Role: role, Ops: "r", }) } return AccessGrantWrite(grants...) } // AccessRevokeAll removes all access grants for a grantee to a specific dossier func AccessRevokeAll(dossierID, granteeID string) error { // Query for matching grants, then delete each by PK grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID, GranteeID: granteeID}) if err != nil { return err } for _, g := range grants { if err := dbDelete("access", "access_id", g.AccessID); err != nil { return err } } return nil } // AccessRevokeEntry removes a specific entry grant for a grantee func AccessRevokeEntry(dossierID, granteeID, entryID string) error { grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID, GranteeID: granteeID, EntryID: entryID}) if err != nil { return err } for _, g := range grants { if err := dbDelete("access", "access_id", g.AccessID); err != nil { return err } } return nil } // --- GENOME QUERIES --- // Note: These functions use nil ctx for internal operations. When called from // API handlers, the API should perform its own RBAC check first. // GenomeGetExtraction returns the extraction entry for genome data func GenomeGetExtraction(ctx *AccessContext, dossierID string) (*Entry, error) { entries, err := EntryList(accessorIDFromContext(ctx), "", CategoryGenome, &EntryFilter{ DossierID: dossierID, Type: "extraction", Limit: 1, }) if err != nil || len(entries) == 0 { return nil, fmt.Errorf("no genome data") } return entries[0], nil } // GenomeTier represents a genome category tier type GenomeTier struct { TierID string Category string } // GenomeGetTiers returns all tier entries for a genome extraction func GenomeGetTiers(ctx *AccessContext, dossierID, extractionID string) ([]GenomeTier, error) { entries, err := EntryList(accessorIDFromContext(ctx), extractionID, CategoryGenome, &EntryFilter{ DossierID: dossierID, Type: "tier", }) if err != nil { return nil, err } var tiers []GenomeTier for _, e := range entries { tiers = append(tiers, GenomeTier{ TierID: e.EntryID, Category: e.Value, // Value holds category name }) } return tiers, nil } // GenomeGetTierByCategory returns a specific tier by category name func GenomeGetTierByCategory(ctx *AccessContext, dossierID, extractionID, category string) (*GenomeTier, error) { entries, err := EntryList(accessorIDFromContext(ctx), extractionID, CategoryGenome, &EntryFilter{ DossierID: dossierID, Type: "tier", Value: category, Limit: 1, }) if err != nil || len(entries) == 0 { return nil, fmt.Errorf("tier not found") } return &GenomeTier{ TierID: entries[0].EntryID, Category: entries[0].Value, }, nil } // GenomeVariant represents a genome variant with its metadata type GenomeVariant struct { EntryID string RSID string Genotype string Gene string Magnitude float64 Repute string Summary string Subcategory string TierID string } // GenomeGetVariants returns variants for specified tier IDs func GenomeGetVariants(ctx *AccessContext, dossierID string, tierIDs []string) ([]GenomeVariant, error) { if len(tierIDs) == 0 { return nil, nil } // Query entries for each tier and deduplicate by type (rsid) seen := make(map[string]bool) var variants []GenomeVariant for _, tierID := range tierIDs { entries, err := EntryList(accessorIDFromContext(ctx), tierID, CategoryGenome, &EntryFilter{DossierID: dossierID}) if err != nil { continue } for _, e := range entries { // Deduplicate by type (rsid) if seen[e.Type] { continue } seen[e.Type] = true var data struct { Mag float64 `json:"mag"` Rep string `json:"rep"` Sum string `json:"sum"` Sub string `json:"sub"` } json.Unmarshal([]byte(e.Data), &data) variants = append(variants, GenomeVariant{ EntryID: e.EntryID, RSID: e.Type, Genotype: e.Value, Gene: e.Tags, Magnitude: data.Mag, Repute: data.Rep, Summary: data.Sum, Subcategory: data.Sub, TierID: tierID, }) } } return variants, nil } // GenomeGetVariantsByTier returns variants for a specific tier func GenomeGetVariantsByTier(ctx *AccessContext, dossierID, tierID string) ([]GenomeVariant, error) { entries, err := EntryList(accessorIDFromContext(ctx), tierID, CategoryGenome, &EntryFilter{DossierID: dossierID}) if err != nil { return nil, err } var variants []GenomeVariant for _, e := range entries { var data struct { Mag float64 `json:"mag"` Rep string `json:"rep"` Sum string `json:"sum"` Sub string `json:"sub"` } json.Unmarshal([]byte(e.Data), &data) variants = append(variants, GenomeVariant{ EntryID: e.EntryID, RSID: e.Type, Genotype: e.Value, Gene: e.Tags, Magnitude: data.Mag, Repute: data.Rep, Summary: data.Sum, Subcategory: data.Sub, TierID: tierID, }) } return variants, nil } // GenomeMatch represents a single genome query result type GenomeMatch struct { RSID string `json:"rsid"` Genotype string `json:"genotype"` Gene string `json:"gene,omitempty"` Magnitude *float64 `json:"magnitude,omitempty"` Repute string `json:"repute,omitempty"` Summary string `json:"summary,omitempty"` Categories []string `json:"categories,omitempty"` } // GenomeQueryResult is the response from GenomeQuery type GenomeQueryResult struct { Matches []GenomeMatch `json:"matches"` Returned int `json:"returned"` Total int `json:"total"` } // GenomeQueryOpts are the filter/sort/pagination options for GenomeQuery type GenomeQueryOpts struct { Category string Search string Gene string // comma-separated RSIDs []string MinMagnitude float64 Repute string // filter by repute (Good, Bad, Clear) IncludeHidden bool Sort string // "magnitude" (default), "gene", "rsid" Offset int Limit int AccessorID string // who is querying (for audit logging) } // GenomeQuery queries genome variants for a dossier. Requires read permission on genome data. // Fast path: gene/rsid use indexed search_key/type columns (precise SQL queries). // Slow path: search/min_magnitude load all variants and filter in memory. func GenomeQuery(ctx *AccessContext, dossierID string, opts GenomeQueryOpts) (*GenomeQueryResult, error) { if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", CategoryGenome, 'r'); err != nil { return nil, err } if opts.IncludeHidden { var details []string if opts.Gene != "" { details = append(details, "gene="+opts.Gene) } if len(opts.RSIDs) > 0 { details = append(details, "rsids="+strings.Join(opts.RSIDs, ",")) } if opts.Search != "" { details = append(details, "search="+opts.Search) } if opts.Category != "" { details = append(details, "category="+opts.Category) } AuditLog(opts.AccessorID, "genome_reveal_hidden", dossierID, strings.Join(details, " ")) } limit := opts.Limit if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } // Fast path: gene or rsid — use indexed columns if opts.Gene != "" || len(opts.RSIDs) > 0 { return genomeQueryFast(dossierID, opts, limit) } // Slow path: search/min_magnitude — load all, filter in memory return genomeQuerySlow(dossierID, opts, limit) } // genomeQueryFast uses indexed search_key (gene) and type (rsid) columns. func genomeQueryFast(dossierID string, opts GenomeQueryOpts, limit int) (*GenomeQueryResult, error) { var entries []Entry if opts.Gene != "" { // Split comma-separated genes, query each via indexed search_key genes := strings.Split(opts.Gene, ",") for i := range genes { genes[i] = strings.TrimSpace(genes[i]) } // Build gene IN clause genePlaceholders := make([]string, len(genes)) args := []any{dossierID, CategoryGenome} for i, gene := range genes { genePlaceholders[i] = "?" args = append(args, CryptoEncrypt(gene)) } sql := "SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND search_key IN (" + strings.Join(genePlaceholders, ",") + ")" // Add rsid filter if specified if len(opts.RSIDs) > 0 { rsidPlaceholders := make([]string, len(opts.RSIDs)) for i, rsid := range opts.RSIDs { rsidPlaceholders[i] = "?" args = append(args, CryptoEncrypt(rsid)) } sql += " AND type IN (" + strings.Join(rsidPlaceholders, ",") + ")" } dbQuery(sql, args, &entries) } else if len(opts.RSIDs) > 0 { // rsid only, no gene — single IN query placeholders := make([]string, len(opts.RSIDs)) args := []any{dossierID, CategoryGenome} for i, rsid := range opts.RSIDs { placeholders[i] = "?" args = append(args, CryptoEncrypt(rsid)) } sql := "SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND type IN (" + strings.Join(placeholders, ",") + ")" dbQuery(sql, args, &entries) } // Look up tier categories for parent_ids (single IN query) tierCategories := make(map[string]string) tierIDSet := make(map[string]bool) for _, e := range entries { tierIDSet[e.ParentID] = true } if len(tierIDSet) > 0 { placeholders := make([]string, 0, len(tierIDSet)) args := make([]any, 0, len(tierIDSet)) for id := range tierIDSet { placeholders = append(placeholders, "?") args = append(args, id) } var tierEntries []Entry dbQuery("SELECT * FROM entries WHERE entry_id IN ("+strings.Join(placeholders, ",")+")", args, &tierEntries) for _, t := range tierEntries { tierCategories[t.EntryID] = t.Value } } return genomeEntriesToResult(entries, tierCategories, opts, limit) } // genomeQuerySlow loads all variants and filters in memory (for search/min_magnitude). func genomeQuerySlow(dossierID string, opts GenomeQueryOpts, limit int) (*GenomeQueryResult, error) { sysCtx := &AccessContext{IsSystem: true} extraction, err := GenomeGetExtraction(sysCtx, dossierID) if err != nil { return nil, fmt.Errorf("GENOME_NO_EXTRACTION: %w", err) } tiers, _ := GenomeGetTiers(sysCtx, dossierID, extraction.EntryID) if len(tiers) == 0 { return &GenomeQueryResult{Matches: []GenomeMatch{}}, nil } tierCategories := make(map[string]string) tierIDs := make([]string, len(tiers)) for i, t := range tiers { tierIDs[i] = t.TierID tierCategories[t.TierID] = t.Category } variants, err := GenomeGetVariants(sysCtx, dossierID, tierIDs) if err != nil { return nil, fmt.Errorf("GENOME_VARIANT_QUERY_FAILED: %w", err) } // Convert to entries for shared result builder var entries []Entry for _, v := range variants { dataJSON, _ := json.Marshal(struct { Mag float64 `json:"mag,omitempty"` Rep string `json:"rep,omitempty"` Sum string `json:"sum,omitempty"` Sub string `json:"sub,omitempty"` }{v.Magnitude, v.Repute, v.Summary, v.Subcategory}) entries = append(entries, Entry{ EntryID: v.EntryID, ParentID: v.TierID, Type: v.RSID, Value: v.Genotype, Tags: v.Gene, Data: string(dataJSON), }) } return genomeEntriesToResult(entries, tierCategories, opts, limit) } // genomeEntriesToResult converts raw entries to GenomeQueryResult with filtering/sorting. func genomeEntriesToResult(entries []Entry, tierCategories map[string]string, opts GenomeQueryOpts, limit int) (*GenomeQueryResult, error) { targeted := opts.Gene != "" || len(opts.RSIDs) > 0 // Dedup by rsid, merge categories seen := make(map[string]*GenomeMatch) var order []string for _, e := range entries { var data struct { Mag float64 `json:"mag"` Rep string `json:"rep"` Sum string `json:"sum"` Sub string `json:"sub"` } if e.Data != "" { json.Unmarshal([]byte(e.Data), &data) } if opts.Search != "" { sl := strings.ToLower(opts.Search) if !strings.Contains(strings.ToLower(e.Tags), sl) && !strings.Contains(strings.ToLower(data.Sum), sl) && !strings.Contains(strings.ToLower(data.Sub), sl) && !strings.Contains(strings.ToLower(e.Type), sl) && !strings.Contains(strings.ToLower(e.Value), sl) { continue } } if opts.Category != "" && tierCategories[e.ParentID] != opts.Category { continue } if opts.MinMagnitude > 0 && data.Mag < opts.MinMagnitude { continue } if opts.Repute != "" && !strings.EqualFold(data.Rep, opts.Repute) { continue } isHidden := data.Mag > 4.0 || strings.EqualFold(data.Rep, "bad") if isHidden && !opts.IncludeHidden && !targeted { continue } cat := tierCategories[e.ParentID] if m, ok := seen[e.Type]; ok { // Already seen this rsid — add category if not duplicate if cat != "" { dup := false for _, c := range m.Categories { if c == cat { dup = true break } } if !dup { m.Categories = append(m.Categories, cat) } } continue } redact := isHidden && !opts.IncludeHidden match := &GenomeMatch{ RSID: e.Type, Gene: e.Tags, } if cat != "" { match.Categories = []string{cat} } if redact { match.Genotype = "hidden" match.Summary = "Sensitive variant hidden. Query with include_hidden=true to reveal." } else { match.Genotype = e.Value if data.Sum != "" { match.Summary = data.Sum } } if data.Mag > 0 { mag := data.Mag match.Magnitude = &mag } if data.Rep != "" { match.Repute = data.Rep } seen[e.Type] = match order = append(order, e.Type) } total := len(order) var matches []GenomeMatch for i, rsid := range order { if i < opts.Offset { continue } if len(matches) >= limit { break } matches = append(matches, *seen[rsid]) } // Sort switch opts.Sort { case "gene": sort.Slice(matches, func(i, j int) bool { return matches[i].Gene < matches[j].Gene }) case "rsid": sort.Slice(matches, func(i, j int) bool { return matches[i].RSID < matches[j].RSID }) default: sort.Slice(matches, func(i, j int) bool { mi, mj := float64(0), float64(0) if matches[i].Magnitude != nil { mi = *matches[i].Magnitude } if matches[j].Magnitude != nil { mj = *matches[j].Magnitude } return mi > mj }) } return &GenomeQueryResult{ Matches: matches, Returned: len(matches), Total: total, }, nil } // --- RBAC-CHECKED QUERY HELPERS --- // EntryCategoryCounts returns entry counts by category for a dossier. func EntryCategoryCounts(ctx *AccessContext, dossierID string) (map[string]int, error) { if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'r'); err != nil { return nil, err } var counts []struct { Category int `db:"category"` Count int `db:"cnt"` } if err := dbQuery("SELECT category, COUNT(*) as cnt FROM entries WHERE dossier_id = ? AND category > 0 GROUP BY category", []any{dossierID}, &counts); err != nil { return nil, err } result := make(map[string]int) for _, c := range counts { name := CategoryName(c.Category) if name != "unknown" { result[name] = c.Count } } return result, nil } // EntryCount returns entry count for a dossier by category and optional type. func EntryCount(ctx *AccessContext, dossierID string, category int, typ string) (int, error) { if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", category, 'r'); err != nil { return 0, err } if typ != "" { return dbCount("SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?", dossierID, category, CryptoEncrypt(typ)) } return dbCount("SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?", dossierID, category) } // EntryListByDossier returns all entries for a dossier ordered by category and timestamp. func EntryListByDossier(ctx *AccessContext, dossierID string) ([]*Entry, error) { if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'r'); err != nil { return nil, err } var entries []*Entry return entries, dbQuery("SELECT * FROM entries WHERE dossier_id = ? ORDER BY category, timestamp", []any{dossierID}, &entries) } // LabTestList returns all lab tests (reference data, no RBAC needed). func LabTestList() ([]LabTest, error) { var tests []LabTest return tests, dbQuery("SELECT loinc_id, name FROM lab_test", nil, &tests) } // LabEntryListForIndex returns lab entries with data for building search indexes. func LabEntryListForIndex() ([]*Entry, error) { var entries []*Entry return entries, dbQuery("SELECT entry_id, data FROM entries WHERE category = ? AND parent_id != ''", []any{CategoryLab}, &entries) } // LabRefListBySource returns lab references matching a source pattern in ref_id. func LabRefListBySource(source string) ([]LabReference, error) { var refs []LabReference return refs, dbQuery("SELECT * FROM lab_reference WHERE ref_id LIKE ?", []any{"%|" + source + "|%"}, &refs) } // LabRefDeleteByID deletes a lab reference by ref_id. func LabRefDeleteByID(refID string) error { return dbDelete("lab_reference", "ref_id", refID) } // --- HELPERS --- func deleteByIDs(table, col string, ids []string) error { for _, id := range ids { if err := dbDelete(table, col, id); err != nil { return err } } return nil }