1605 lines
42 KiB
Go
1605 lines
42 KiB
Go
package lib
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"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 := &Entry{}
|
|
if err := dbLoad("entries", id, e); 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 := &Entry{}
|
|
if err := dbLoad("entries", id, e); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// RBAC: Check read permission
|
|
if !CheckAccess(accessorIDFromContext(ctx), e.DossierID, id, PermRead) {
|
|
return nil, fmt.Errorf("access denied")
|
|
}
|
|
|
|
return e, nil
|
|
}
|
|
|
|
// EntryList retrieves entries. Requires read permission on parent/dossier.
|
|
// accessorID: who is asking (SystemAccessorID = internal operations)
|
|
// dossierID comes from f.DossierID
|
|
// EntryQuery is the ONLY choke point for querying entries. All queries go through here.
|
|
// Takes ctx for RBAC, dossierID (required), category, type, and parent for filtering.
|
|
func EntryQuery(ctx *AccessContext, dossierID string, category int, typ, parent string) ([]*Entry, error) {
|
|
// RBAC: Check read permission
|
|
if !CheckAccess(accessorIDFromContext(ctx), dossierID, parent, PermRead) {
|
|
return nil, fmt.Errorf("access denied")
|
|
}
|
|
|
|
q := "SELECT * FROM entries WHERE dossier_id = ?"
|
|
args := []any{dossierID}
|
|
|
|
if category > 0 {
|
|
q += " AND category = ?"
|
|
args = append(args, category)
|
|
}
|
|
|
|
if typ != "" {
|
|
q += " AND type = ?"
|
|
args = append(args, CryptoEncrypt(typ))
|
|
}
|
|
|
|
if parent == "" {
|
|
q += " AND (parent_id IS NULL OR parent_id = '')"
|
|
} else if parent != "*" { // "*" means "any parent" (don't filter)
|
|
q += " AND parent_id = ?"
|
|
args = append(args, parent)
|
|
}
|
|
|
|
q += " ORDER BY timestamp, ordinal"
|
|
|
|
var result []*Entry
|
|
err := dbQuery(q, args, &result)
|
|
return result, err
|
|
}
|
|
|
|
// EntryList is a convenience wrapper around EntryQuery for filter-based queries.
|
|
// All queries are routed through EntryQuery.
|
|
func EntryList(accessorID string, parent string, category int, f *EntryFilter) ([]*Entry, error) {
|
|
dossierID := ""
|
|
typ := ""
|
|
|
|
if f != nil {
|
|
dossierID = f.DossierID
|
|
typ = f.Type
|
|
}
|
|
|
|
// Build context from accessorID
|
|
var ctx *AccessContext
|
|
if accessorID != "" {
|
|
ctx = &AccessContext{AccessorID: accessorID}
|
|
}
|
|
|
|
return EntryQuery(ctx, dossierID, category, typ, parent)
|
|
}
|
|
|
|
// --- DOSSIER ---
|
|
|
|
type DossierFilter struct {
|
|
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()
|
|
}
|
|
// Normalize email to lowercase
|
|
if d.Email != "" {
|
|
d.Email = strings.ToLower(strings.TrimSpace(d.Email))
|
|
}
|
|
// 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")
|
|
}
|
|
|
|
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.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]
|
|
}
|
|
|
|
// --- ACCESS (RBAC) ---
|
|
// 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("access", records[0])
|
|
}
|
|
return dbSave("access", records)
|
|
}
|
|
|
|
func AccessRemove(accessorID, targetID string) error {
|
|
access, err := AccessGet(accessorID, targetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return dbDelete("access", "access_id", access.AccessID)
|
|
}
|
|
|
|
func AccessGet(accessorID, targetID string) (*Access, error) {
|
|
q := "SELECT * FROM access WHERE grantee_id = ? AND 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 access WHERE 1=1"
|
|
args := []any{}
|
|
|
|
if f != nil {
|
|
if f.AccessorID != "" {
|
|
q += " AND grantee_id = ?"
|
|
args = append(args, f.AccessorID)
|
|
}
|
|
if f.TargetID != "" {
|
|
q += " AND dossier_id = ?"
|
|
args = append(args, f.TargetID)
|
|
}
|
|
}
|
|
|
|
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 := &Entry{}
|
|
if err := dbLoad("entries", id, e); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// RBAC: Check read permission
|
|
if !CheckAccess(accessorIDFromContext(ctx), e.DossierID, id, PermRead) {
|
|
return nil, fmt.Errorf("access denied")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
raw, err := os.ReadFile(ObjectPath(e.DossierID, id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dec, err := CryptoDecryptBytes(raw)
|
|
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 - MANDATORY ACCESS FUNCTIONS
|
|
// ============================================================================
|
|
//
|
|
// ⚠️ CRITICAL: ALL object access MUST go through these functions.
|
|
// ⚠️ NEVER use os.ReadFile/WriteFile directly on ObjectPath.
|
|
// ⚠️ NEVER bypass RBAC checks.
|
|
//
|
|
// These functions enforce:
|
|
// - RBAC permissions (read/write/delete checked per entry)
|
|
// - Encryption at rest (all objects encrypted)
|
|
// - Consistent error handling
|
|
//
|
|
// Object naming: /objects/{dossierID}/{entryID[0:2]}/{entryID}
|
|
// - Object filename = entry ID
|
|
// - RBAC check: CheckAccess(accessor, dossierID, entryID, permission)
|
|
// - Access to object = access to entry metadata
|
|
//
|
|
// ============================================================================
|
|
|
|
// ObjectWrite compresses (gzip), encrypts, and writes data to the object store.
|
|
// Requires write permission. Compression is always applied for consistency.
|
|
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")
|
|
}
|
|
|
|
// Compress with gzip (stdlib, rock-solid)
|
|
var buf bytes.Buffer
|
|
gzipWriter := gzip.NewWriter(&buf)
|
|
if _, err := gzipWriter.Write(data); err != nil {
|
|
gzipWriter.Close()
|
|
return fmt.Errorf("gzip compression failed: %w", err)
|
|
}
|
|
if err := gzipWriter.Close(); err != nil {
|
|
return fmt.Errorf("gzip close failed: %w", err)
|
|
}
|
|
|
|
// Encrypt compressed data
|
|
compressed := buf.Bytes()
|
|
encrypted := CryptoEncryptBytes(compressed)
|
|
|
|
// Write to disk
|
|
path := ObjectPath(dossierID, entryID)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, encrypted, 0644)
|
|
}
|
|
|
|
// ObjectRead reads, decrypts, and decompresses data from the object store.
|
|
// Requires read permission. Decompression is always applied (matches ObjectWrite).
|
|
func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) {
|
|
accessorID := accessorIDFromContext(ctx)
|
|
// RBAC: Check read permission
|
|
if !CheckAccess(accessorID, dossierID, entryID, PermRead) {
|
|
fmt.Printf("[ObjectRead] RBAC DENIED: accessor=%s dossier=%s entry=%s\n", accessorID, dossierID, entryID)
|
|
return nil, fmt.Errorf("access denied")
|
|
}
|
|
fmt.Printf("[ObjectRead] RBAC OK: accessor=%s dossier=%s entry=%s\n", accessorID, dossierID, entryID)
|
|
|
|
// Read and decrypt
|
|
raw, err := os.ReadFile(ObjectPath(dossierID, entryID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decrypted, err := CryptoDecryptBytes(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decryption failed: %w", err)
|
|
}
|
|
|
|
// Decompress with gzip
|
|
gzipReader, err := gzip.NewReader(bytes.NewReader(decrypted))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gzip decompression failed: %w", err)
|
|
}
|
|
defer gzipReader.Close()
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := buf.ReadFrom(gzipReader); err != nil {
|
|
return nil, fmt.Errorf("gzip read failed: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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 old access entries to new RBAC format (one-time migration)
|
|
// DEPRECATED: Migration already completed, kept for reference only
|
|
func MigrateOldAccess() int {
|
|
type oldAccess struct {
|
|
AccessorID string `db:"grantee_id"`
|
|
TargetID string `db:"dossier_id"`
|
|
CanEdit int `db:"can_edit"`
|
|
}
|
|
var entries []oldAccess
|
|
err := dbQuery("SELECT grantee_id, dossier_id, 1 as can_edit FROM access WHERE 1=0", 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)
|
|
}
|
|
|
|
// 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)
|
|
// DossierQueryRow represents one row from DossierQuery (dossier + category)
|
|
type DossierQueryRow struct {
|
|
Dossier // Embed full dossier fields
|
|
Category int `db:"category"`
|
|
EntryCount int `db:"entry_count"`
|
|
}
|
|
|
|
// DossierQuery returns accessible dossiers with their accessible categories (RBAC-enforced JOIN query)
|
|
// Returns one row per dossier-category combination. Caller groups by dossier_id as needed.
|
|
func DossierQuery(accessorID string) ([]*DossierQueryRow, error) {
|
|
var rows []*DossierQueryRow
|
|
query := `
|
|
SELECT
|
|
d.*,
|
|
e.category,
|
|
COUNT(e.entry_id) as entry_count
|
|
FROM dossiers d
|
|
LEFT JOIN access a ON d.dossier_id = a.dossier_id AND a.grantee_id = ?
|
|
LEFT JOIN entries e ON e.dossier_id = d.dossier_id
|
|
AND (
|
|
? = d.dossier_id
|
|
OR EXISTS (
|
|
SELECT 1 FROM access a2
|
|
WHERE a2.grantee_id = ?
|
|
AND a2.dossier_id = d.dossier_id
|
|
AND a2.entry_id = d.dossier_id
|
|
AND (a2.ops & 1) != 0
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1 FROM access a3
|
|
WHERE a3.grantee_id = ?
|
|
AND a3.entry_id = e.entry_id
|
|
AND (a3.ops & 1) != 0
|
|
)
|
|
)
|
|
WHERE d.dossier_id = ? OR (a.ops & 1) != 0
|
|
GROUP BY d.dossier_id, e.category
|
|
ORDER BY d.dossier_id, e.category
|
|
`
|
|
|
|
err := dbQuery(query, []any{accessorID, accessorID, accessorID, accessorID, accessorID}, &rows)
|
|
return rows, err
|
|
}
|