inou/lib/v2.go

1604 lines
42 KiB
Go

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 !CheckAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, PermWrite) {
return fmt.Errorf("access denied")
}
}
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 !CheckAccess(accessorIDFromContext(ctx), e.DossierID, id, PermDelete) {
return fmt.Errorf("access denied")
}
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, "", PermDelete) {
return fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), e.DossierID, id, PermRead) {
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 !CheckAccess(accessorID, dossierID, parent, PermRead) {
return nil, fmt.Errorf("access denied")
}
}
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 !CheckAccess(accessorIDFromContext(ctx), d.DossierID, "", PermManage) {
return fmt.Errorf("access denied")
}
}
// 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 !CheckAccess(accessorIDFromContext(ctx), id, "", PermManage) {
return fmt.Errorf("access denied")
}
}
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 !CheckAccess(accessorIDFromContext(ctx), id, "", PermRead) {
return nil, fmt.Errorf("access denied")
}
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, fmt.Errorf("access denied")
}
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, fmt.Errorf("access denied")
}
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 ...*Access) 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) (*Access, error) {
q := "SELECT * FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ?"
var result []*Access
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) ([]*Access, 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 []*Access
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 !CheckAccess(accessorIDFromContext(ctx), e.DossierID, id, PermRead) {
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, entryID, PermWrite) {
return fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, entryID, PermRead) {
return nil, fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, entryID, PermDelete) {
return fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, "", PermDelete) {
return fmt.Errorf("access denied")
}
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.GranteeID); err == nil && d != nil {
name = d.Name
}
result = append(result, map[string]interface{}{
"accessor_id": a.GranteeID,
"name": name,
"relation": a.Relation,
"is_care_receiver": false,
"can_edit": (a.Ops & PermWrite) != 0,
})
}
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
AccessGrantWrite(&Access{
DossierID: e.TargetID,
GranteeID: e.AccessorID,
EntryID: "",
Ops: PermRead | PermWrite,
})
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 "" != role {
continue
}
grants = append(grants, &Access{
DossierID: dossierID,
GranteeID: granteeID,
EntryID: t.EntryID,
})
}
if len(grants) == 0 {
// No template found, create default read-only grant
grants = append(grants, &Access{
DossierID: dossierID,
GranteeID: granteeID,
})
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, "", PermRead) {
return nil, fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, "", PermRead) {
return nil, fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, "", PermRead) {
return 0, fmt.Errorf("access denied")
}
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 !CheckAccess(accessorIDFromContext(ctx), dossierID, "", PermRead) {
return nil, fmt.Errorf("access denied")
}
var entries []*Entry
return entries, dbQuery("SELECT * FROM entries WHERE dossier_id = ? ORDER BY category, timestamp", []any{dossierID}, &entries)
}
// 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)
}
// --- 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
}
// DossierQuery returns all dossiers the accessor has access to (RBAC choke point)
func DossierQuery(accessorID string) ([]*Dossier, error) {
// Get all grants for this accessor
grants, err := ListGrants("", accessorID)
if err != nil {
return nil, err
}
// Collect unique dossier IDs
dossierIDsMap := make(map[string]bool)
dossierIDsMap[accessorID] = true // Always include self
for _, g := range grants {
if g.Ops & PermRead != 0 {
dossierIDsMap[g.DossierID] = true
}
}
// Build IN clause
var dossierIDs []string
for id := range dossierIDsMap {
dossierIDs = append(dossierIDs, id)
}
if len(dossierIDs) == 0 {
return []*Dossier{}, nil
}
// Query dossiers
var dossiers []*Dossier
placeholders := make([]string, len(dossierIDs))
args := make([]any, len(dossierIDs))
for i, id := range dossierIDs {
placeholders[i] = "?"
args[i] = id
}
query := "SELECT * FROM dossiers WHERE dossier_id IN (" + strings.Join(placeholders, ",") + ")"
if err := dbQuery(query, args, &dossiers); err != nil {
return nil, err
}
return dossiers, nil
}