1100 lines
27 KiB
Go
1100 lines
27 KiB
Go
package lib
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
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, 'w'); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.EntryID == "" {
|
|
e.EntryID = NewID()
|
|
}
|
|
}
|
|
if len(entries) == 1 {
|
|
return Save("entries", entries[0])
|
|
}
|
|
return Save("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, '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, "", 'd'); err != nil {
|
|
return err
|
|
}
|
|
|
|
var entries []*Entry
|
|
if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ?", []any{dossierID}, &entries); err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if err := Delete("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, '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, Load("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, '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.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 := Query(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, "", '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 Save("dossiers", dossiers[0])
|
|
}
|
|
return Save("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, "", '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, "", '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 := Load("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 := Query(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 := Query(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 := Query(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 Save("dossier_access", records[0])
|
|
}
|
|
return Save("dossier_access", records)
|
|
}
|
|
|
|
func AccessRemove(accessorID, targetID string) error {
|
|
access, err := AccessGet(accessorID, targetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return Delete("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 := Query(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 := Query(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 Save("audit", entries[0])
|
|
}
|
|
return Save("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 := Query(q, args, &result)
|
|
return result, err
|
|
}
|
|
|
|
// --- PROMPT ---
|
|
|
|
type PromptFilter struct {
|
|
DossierID string
|
|
Category string
|
|
Type string
|
|
ActiveOnly bool
|
|
Limit int
|
|
}
|
|
|
|
func PromptWrite(prompts ...*Prompt) error {
|
|
if len(prompts) == 0 {
|
|
return nil
|
|
}
|
|
for _, p := range prompts {
|
|
if p.PromptID == "" {
|
|
p.PromptID = NewID()
|
|
}
|
|
}
|
|
if len(prompts) == 1 {
|
|
return Save("prompts", prompts[0])
|
|
}
|
|
return Save("prompts", prompts)
|
|
}
|
|
|
|
func PromptRemove(ids ...string) error {
|
|
return deleteByIDs("prompts", "prompt_id", ids)
|
|
}
|
|
|
|
func PromptList(f *PromptFilter) ([]*Prompt, error) {
|
|
q := "SELECT * FROM prompts 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 []*Prompt
|
|
err := Query(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, '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, '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, '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, '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, "", '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
|
|
}
|
|
|
|
// PromptDistinctTypes returns distinct category/type pairs for a dossier's active prompts
|
|
func PromptDistinctTypes(dossierID string) (map[string][]string, error) {
|
|
var prompts []*Prompt
|
|
if err := Query("SELECT * FROM prompts WHERE dossier_id = ? AND active = 1", []any{dossierID}, &prompts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract distinct category/type pairs
|
|
seen := make(map[string]bool)
|
|
result := make(map[string][]string)
|
|
for _, p := range prompts {
|
|
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 Save("access", grants[0])
|
|
}
|
|
return Save("access", grants)
|
|
}
|
|
|
|
// AccessGrantRemove removes access grants by ID
|
|
func AccessGrantRemove(ids ...string) error {
|
|
for _, id := range ids {
|
|
if err := Delete("access", "access_id", id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AccessGrantGet retrieves a single access grant by ID
|
|
func AccessGrantGet(id string) (*Access, error) {
|
|
a := &Access{}
|
|
return a, Load("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 := Query(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 := Query(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 := Delete("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 := Delete("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
|
|
}
|
|
|
|
// --- HELPERS ---
|
|
|
|
func deleteByIDs(table, col string, ids []string) error {
|
|
for _, id := range ids {
|
|
if err := Delete(table, col, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|