package sharing import ( "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/idempotency" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrAccessDenied = errors.New("sharing access denied") ErrInvalid = errors.New("sharing grant is invalid") ErrNotFound = errors.New("sharing resource not found") ErrExpired = errors.New("sharing grant expired or revoked") ErrVersionConflict = errors.New("shared resource version conflict") ) type mediaSigner interface { PresignMediaDownload(context.Context, string, time.Duration, string, string, bool) (string, error) } type Service struct { pool *pgxpool.Pool audit *audit.Repository idem *idempotency.Executor store mediaSigner accessTTL time.Duration tokenSecret []byte } type CreateInput struct { ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` GranteeUserID string `json:"grantee_user_id"` Permission string `json:"permission"` Purpose string `json:"purpose"` ExpiresAt time.Time `json:"expires_at"` LoginRequired bool `json:"login_required"` } type Grant struct { ID string `json:"id"` SubjectID string `json:"subject_id"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` GranteeUserID string `json:"grantee_user_id"` Permission string `json:"permission"` Purpose string `json:"purpose"` LoginRequired bool `json:"login_required"` GrantedBy string `json:"granted_by"` GrantedAt time.Time `json:"granted_at"` ExpiresAt time.Time `json:"expires_at"` RevokedAt *time.Time `json:"revoked_at,omitempty"` Version int64 `json:"version"` AccessToken string `json:"access_token,omitempty"` } type SharedTurn struct { SequenceNo int64 `json:"sequence_no"` Speaker string `json:"speaker"` StartAt time.Time `json:"start_at"` EndAt *time.Time `json:"end_at,omitempty"` Text string `json:"text"` Interrupted bool `json:"interrupted"` } type SharedResource struct { GrantID string `json:"grant_id"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` Permission string `json:"permission"` Title string `json:"title,omitempty"` Text string `json:"text,omitempty"` Turns []SharedTurn `json:"turns,omitempty"` AccessURL string `json:"access_url,omitempty"` AccessUntil *time.Time `json:"access_until,omitempty"` Version int64 `json:"version,omitempty"` RevisionNo int64 `json:"revision_no,omitempty"` } type resourceBoundary struct{ tenantID, householdID, subjectID, subjectPublicID string } func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, store mediaSigner, tokenSecret []byte, accessTTL time.Duration) (*Service, error) { if pool == nil || auditRepository == nil || store == nil || len(tokenSecret) < 32 || accessTTL <= 0 { return nil, errors.New("sharing database, audit, object store, token secret and access TTL are required") } return &Service{pool: pool, audit: auditRepository, idem: idempotency.NewExecutor(pool), store: store, accessTTL: accessTTL, tokenSecret: append([]byte(nil), tokenSecret...)}, nil } func normalizeInput(input CreateInput) (CreateInput, error) { input.ResourceType = strings.ToUpper(strings.TrimSpace(input.ResourceType)) input.Permission = strings.ToUpper(strings.TrimSpace(input.Permission)) input.Purpose = strings.TrimSpace(input.Purpose) valid := (input.ResourceType == "STORY" && (input.Permission == "VIEW_TEXT" || input.Permission == "EDIT")) || (input.ResourceType == "TRANSCRIPT" && input.Permission == "VIEW_TEXT") || (input.ResourceType == "RECORDING" && (input.Permission == "LISTEN" || input.Permission == "DOWNLOAD")) if !valid || (!input.LoginRequired && input.Permission == "EDIT") || input.ResourceID == "" || input.GranteeUserID == "" || input.Purpose == "" || len([]rune(input.Purpose)) > 500 || input.ExpiresAt.Before(time.Now().UTC().Add(time.Minute)) { return CreateInput{}, ErrInvalid } return input, nil } func (s *Service) Create(ctx context.Context, principal identity.Principal, key, requestID string, raw CreateInput) (Grant, bool, error) { input, err := normalizeInput(raw) if err != nil { return Grant{}, false, err } boundary, grantorID, grantorPublicID, err := s.resolveGrantor(ctx, s.pool, principal, input.ResourceType, input.ResourceID, false) if err != nil { return Grant{}, false, err } canonical, _ := json.Marshal(input) grantPublicID := s.deriveGrantID(principal.Issuer + "|" + principal.Subject + "|" + key + "|" + idempotency.Hash(canonical)) var tokenDigest any if !input.LoginRequired { tokenDigest = digest(s.deriveAccessToken(grantPublicID)) } result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &boundary.tenantID, ActorKey: principal.Issuer + "|" + principal.Subject, Scope: "sharing-grant.create", Key: key, RequestHash: idempotency.Hash(canonical), ExpiresAfter: 24 * time.Hour}, func(ctx context.Context, tx pgx.Tx) (idempotency.Result, error) { locked, actorID, actorPublicID, err := s.resolveGrantor(ctx, tx, principal, input.ResourceType, input.ResourceID, true) if err != nil { return idempotency.Result{}, err } if locked.tenantID != boundary.tenantID || actorID != grantorID { return idempotency.Result{}, ErrAccessDenied } var granteeID string if err := tx.QueryRow(ctx, `SELECT id::text FROM user_account WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='active'`, locked.tenantID, input.GranteeUserID).Scan(&granteeID); errors.Is(err, pgx.ErrNoRows) { return idempotency.Result{}, ErrAccessDenied } else if err != nil { return idempotency.Result{}, err } if _, err := tx.Exec(ctx, `UPDATE sharing_grant SET revoked_at=now(),revoked_by=$1::uuid,version=version+1,updated_at=now() WHERE tenant_id=$2::uuid AND resource_type=$3 AND resource_public_id=$4::uuid AND grantee_user_id=$5::uuid AND permission=$6 AND revoked_at IS NULL AND expires_at>now()`, actorID, locked.tenantID, input.ResourceType, input.ResourceID, granteeID, input.Permission); err != nil { return idempotency.Result{}, err } var item Grant err = tx.QueryRow(ctx, `INSERT INTO sharing_grant(public_id,tenant_id,household_id,subject_id,resource_type,resource_public_id,grantee_user_id,permission,purpose,login_required,access_token_digest,granted_by,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6::uuid,$7::uuid,$8,$9,$10,$11,$12::uuid,$13) RETURNING public_id::text,$14::text,resource_type,resource_public_id::text,$15::text,permission,purpose,login_required,$16::text,granted_at,expires_at,revoked_at,version`, grantPublicID, locked.tenantID, locked.householdID, locked.subjectID, input.ResourceType, input.ResourceID, granteeID, input.Permission, input.Purpose, input.LoginRequired, tokenDigest, actorID, input.ExpiresAt, locked.subjectPublicID, input.GranteeUserID, actorPublicID).Scan(&item.ID, &item.SubjectID, &item.ResourceType, &item.ResourceID, &item.GranteeUserID, &item.Permission, &item.Purpose, &item.LoginRequired, &item.GrantedBy, &item.GrantedAt, &item.ExpiresAt, &item.RevokedAt, &item.Version) if err != nil { return idempotency.Result{}, err } if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &locked.tenantID, ActorType: "user", ActorPublicID: &actorPublicID, Action: "sharing.grant.create", ResourceType: "sharing_grant", ResourceID: &item.ID, RequestID: requestID, Purpose: input.Purpose, Result: "success", Detail: map[string]any{"resource_type": input.ResourceType, "resource_id": input.ResourceID, "grantee_user_id": input.GranteeUserID, "permission": input.Permission, "expires_at": input.ExpiresAt}}); err != nil { return idempotency.Result{}, err } body, _ := json.Marshal(item) return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &item.ID}, nil }) if err != nil { return Grant{}, false, err } var item Grant if err := json.Unmarshal(result.Body, &item); err != nil { return Grant{}, false, err } if !item.LoginRequired { item.AccessToken = s.deriveAccessToken(item.ID) } _ = grantorPublicID return item, !result.Replayed, nil } func (s *Service) deriveGrantID(value string) string { mac := hmac.New(sha256.New, s.tokenSecret) _, _ = mac.Write([]byte("sharing-grant:" + value)) sum := mac.Sum(nil) sum[6] = (sum[6] & 0x0f) | 0x40 sum[8] = (sum[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) } func (s *Service) deriveAccessToken(grantID string) string { mac := hmac.New(sha256.New, s.tokenSecret) _, _ = mac.Write([]byte("sharing-access:" + grantID)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func digest(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func (s *Service) List(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]Grant, error) { var tenantID, subjectID string err := s.pool.QueryRow(ctx, `SELECT sp.tenant_id::text,sp.id::text FROM subject_profile sp JOIN household_member hm ON hm.tenant_id=sp.tenant_id AND hm.household_id=sp.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=sp.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' WHERE sp.public_id=$1::uuid AND sp.status='active' AND hm.role IN('owner','admin')`, subjectPublicID, principal.Issuer, principal.Subject).Scan(&tenantID, &subjectID) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrAccessDenied } if err != nil { return nil, err } rows, err := s.pool.Query(ctx, `SELECT sg.public_id::text,$3::text,sg.resource_type,sg.resource_public_id::text,grantee.public_id::text,sg.permission,sg.purpose,sg.login_required,grantor.public_id::text,sg.granted_at,sg.expires_at,sg.revoked_at,sg.version FROM sharing_grant sg JOIN user_account grantee ON grantee.id=sg.grantee_user_id AND grantee.tenant_id=sg.tenant_id JOIN user_account grantor ON grantor.id=sg.granted_by AND grantor.tenant_id=sg.tenant_id WHERE sg.tenant_id=$1::uuid AND sg.subject_id=$2::uuid ORDER BY sg.granted_at DESC LIMIT 200`, tenantID, subjectID, subjectPublicID) if err != nil { return nil, err } defer rows.Close() items := make([]Grant, 0) for rows.Next() { var item Grant if err := rows.Scan(&item.ID, &item.SubjectID, &item.ResourceType, &item.ResourceID, &item.GranteeUserID, &item.Permission, &item.Purpose, &item.LoginRequired, &item.GrantedBy, &item.GrantedAt, &item.ExpiresAt, &item.RevokedAt, &item.Version); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Service) Revoke(ctx context.Context, principal identity.Principal, grantPublicID, requestID string) (Grant, error) { var item Grant tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return item, err } defer tx.Rollback(context.Background()) var tenantID, actorID, actorPublicID, role string err = tx.QueryRow(ctx, `SELECT sg.tenant_id::text,ua.id::text,ua.public_id::text,hm.role,sg.public_id::text,sp.public_id::text,sg.resource_type,sg.resource_public_id::text,grantee.public_id::text,sg.permission,sg.purpose,sg.login_required,grantor.public_id::text,sg.granted_at,sg.expires_at,sg.revoked_at,sg.version FROM sharing_grant sg JOIN subject_profile sp ON sp.id=sg.subject_id AND sp.tenant_id=sg.tenant_id JOIN household_member hm ON hm.tenant_id=sg.tenant_id AND hm.household_id=sg.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=sg.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' JOIN user_account grantee ON grantee.id=sg.grantee_user_id JOIN user_account grantor ON grantor.id=sg.granted_by WHERE sg.public_id=$1::uuid FOR UPDATE OF sg`, grantPublicID, principal.Issuer, principal.Subject).Scan(&tenantID, &actorID, &actorPublicID, &role, &item.ID, &item.SubjectID, &item.ResourceType, &item.ResourceID, &item.GranteeUserID, &item.Permission, &item.Purpose, &item.LoginRequired, &item.GrantedBy, &item.GrantedAt, &item.ExpiresAt, &item.RevokedAt, &item.Version) if errors.Is(err, pgx.ErrNoRows) { return item, ErrNotFound } if err != nil { return item, err } if role != "owner" && role != "admin" { return item, ErrAccessDenied } if item.RevokedAt == nil { err = tx.QueryRow(ctx, `UPDATE sharing_grant SET revoked_at=now(),revoked_by=$2::uuid,version=version+1,updated_at=now() WHERE public_id=$1::uuid RETURNING revoked_at,version`, grantPublicID, actorID).Scan(&item.RevokedAt, &item.Version) if err != nil { return item, err } } if err = s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "user", ActorPublicID: &actorPublicID, Action: "sharing.grant.revoke", ResourceType: "sharing_grant", ResourceID: &item.ID, RequestID: requestID, Result: "success"}); err != nil { return item, err } if err = tx.Commit(ctx); err != nil { return item, err } return item, nil } func (s *Service) Access(ctx context.Context, principal identity.Principal, grantPublicID, requestID string) (SharedResource, error) { digestBytes := sha256.Sum256([]byte(principal.Issuer + "|" + principal.Subject)) actorDigest := hex.EncodeToString(digestBytes[:]) var grantID, tenantID, userID, resourceType, resourceID, permission string var allowed, active bool err := s.pool.QueryRow(ctx, `SELECT sg.id::text,sg.tenant_id::text,COALESCE(ua.id::text,''),sg.resource_type,sg.resource_public_id::text,sg.permission,COALESCE(ua.id=sg.grantee_user_id,false), (sg.revoked_at IS NULL AND sg.expires_at>now()) FROM sharing_grant sg LEFT JOIN user_account ua ON ua.tenant_id=sg.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' WHERE sg.public_id=$1::uuid`, grantPublicID, principal.Issuer, principal.Subject).Scan(&grantID, &tenantID, &userID, &resourceType, &resourceID, &permission, &allowed, &active) if errors.Is(err, pgx.ErrNoRows) { return SharedResource{}, ErrNotFound } if err != nil { return SharedResource{}, err } if !active { if logErr := s.logAccess(ctx, tenantID, grantID, userID, actorDigest, resourceType, resourceID, permission, "DENIED", "GRANT_INACTIVE", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrExpired } if !allowed { if logErr := s.logAccess(ctx, tenantID, grantID, userID, actorDigest, resourceType, resourceID, permission, "DENIED", "RECIPIENT_MISMATCH", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrAccessDenied } shared := SharedResource{GrantID: grantPublicID, ResourceType: resourceType, ResourceID: resourceID, Permission: permission} switch resourceType { case "STORY": err = s.pool.QueryRow(ctx, `SELECT sd.title,sr.content,sd.version,sr.revision_no FROM story_draft sd JOIN story_revision sr ON sr.id=sd.current_revision_id WHERE sd.public_id=$1::uuid AND sd.status<>'SUPERSEDED' AND call_story_available(sd.id)`, resourceID).Scan(&shared.Title, &shared.Text, &shared.Version, &shared.RevisionNo) case "TRANSCRIPT": rows, qerr := s.pool.Query(ctx, `SELECT ct.sequence_no,ct.speaker,ct.start_at,ct.end_at,ct.final_text,ct.interrupted FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id=$1::uuid ORDER BY ct.sequence_no LIMIT 1000`, resourceID) if qerr != nil { return SharedResource{}, qerr } defer rows.Close() shared.Turns = []SharedTurn{} for rows.Next() { var turn SharedTurn if err := rows.Scan(&turn.SequenceNo, &turn.Speaker, &turn.StartAt, &turn.EndAt, &turn.Text, &turn.Interrupted); err != nil { return SharedResource{}, err } shared.Turns = append(shared.Turns, turn) } err = rows.Err() case "RECORDING": var objectRef, contentType, track, conversationPublicID string var sequence int var consent bool err = s.pool.QueryRow(ctx, `SELECT ms.object_ref,ms.content_type,ms.track,ms.sequence_no,c.public_id::text,EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=ms.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='raw_audio_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())) FROM media_segment ms JOIN conversation c ON c.id=ms.conversation_id WHERE ms.public_id=$1::uuid AND ms.status='VERIFIED'`, resourceID).Scan(&objectRef, &contentType, &track, &sequence, &conversationPublicID, &consent) if err == nil && !consent { if logErr := s.logAccess(ctx, tenantID, grantID, userID, actorDigest, resourceType, resourceID, permission, "DENIED", "CONSENT_REQUIRED", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrAccessDenied } if err == nil { extension := ".bin" if contentType == "audio/wav" { extension = ".wav" } else if contentType == "audio/opus" { extension = ".opus" } else if contentType == "audio/ogg" { extension = ".ogg" } until := time.Now().UTC().Add(s.accessTTL) shared.AccessURL, err = s.store.PresignMediaDownload(ctx, objectRef, s.accessTTL, fmt.Sprintf("%s-%s-%08d%s", conversationPublicID, track, sequence, extension), contentType, permission == "LISTEN") shared.AccessUntil = &until } default: return SharedResource{}, ErrInvalid } if errors.Is(err, pgx.ErrNoRows) { if logErr := s.logAccess(ctx, tenantID, grantID, userID, actorDigest, resourceType, resourceID, permission, "DENIED", "RESOURCE_UNAVAILABLE", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrNotFound } if err != nil { return SharedResource{}, err } if logErr := s.logAccess(ctx, tenantID, grantID, userID, actorDigest, resourceType, resourceID, permission, "SUCCESS", "", requestID); logErr != nil { return SharedResource{}, logErr } return shared, nil } func (s *Service) AccessByToken(ctx context.Context, grantPublicID, token, requestID string) (SharedResource, error) { token = strings.TrimSpace(token) actorDigest := digest(token) var grantID, tenantID, resourceType, resourceID, permission, storedDigest string var active, loginRequired bool err := s.pool.QueryRow(ctx, `SELECT id::text,tenant_id::text,resource_type,resource_public_id::text,permission,COALESCE(access_token_digest,''),(revoked_at IS NULL AND expires_at>now()),login_required FROM sharing_grant WHERE public_id=$1::uuid`, grantPublicID).Scan(&grantID, &tenantID, &resourceType, &resourceID, &permission, &storedDigest, &active, &loginRequired) if errors.Is(err, pgx.ErrNoRows) { return SharedResource{}, ErrNotFound } if err != nil { return SharedResource{}, err } validToken := len(token) >= 32 && subtle.ConstantTimeCompare([]byte(actorDigest), []byte(storedDigest)) == 1 if loginRequired || !validToken { if logErr := s.logAccess(ctx, tenantID, grantID, "", actorDigest, resourceType, resourceID, permission, "DENIED", "TOKEN_INVALID", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrAccessDenied } if !active { if logErr := s.logAccess(ctx, tenantID, grantID, "", actorDigest, resourceType, resourceID, permission, "DENIED", "GRANT_INACTIVE", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrExpired } shared, err := s.loadResource(ctx, grantPublicID, resourceType, resourceID, permission) if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, ErrNotFound) { _ = s.logAccess(ctx, tenantID, grantID, "", actorDigest, resourceType, resourceID, permission, "DENIED", "RESOURCE_UNAVAILABLE", requestID) return SharedResource{}, ErrNotFound } if errors.Is(err, ErrAccessDenied) { if logErr := s.logAccess(ctx, tenantID, grantID, "", actorDigest, resourceType, resourceID, permission, "DENIED", "CONSENT_REQUIRED", requestID); logErr != nil { return SharedResource{}, logErr } return SharedResource{}, ErrAccessDenied } if err != nil { return SharedResource{}, err } if err = s.logAccess(ctx, tenantID, grantID, "", actorDigest, resourceType, resourceID, permission, "SUCCESS", "", requestID); err != nil { return SharedResource{}, err } return shared, nil } func (s *Service) loadResource(ctx context.Context, grantPublicID, resourceType, resourceID, permission string) (SharedResource, error) { shared := SharedResource{GrantID: grantPublicID, ResourceType: resourceType, ResourceID: resourceID, Permission: permission} switch resourceType { case "STORY": err := s.pool.QueryRow(ctx, `SELECT sd.title,sr.content,sd.version,sr.revision_no FROM story_draft sd JOIN story_revision sr ON sr.id=sd.current_revision_id WHERE sd.public_id=$1::uuid AND sd.status<>'SUPERSEDED' AND call_story_available(sd.id)`, resourceID).Scan(&shared.Title, &shared.Text, &shared.Version, &shared.RevisionNo) return shared, err case "TRANSCRIPT": rows, err := s.pool.Query(ctx, `SELECT ct.sequence_no,ct.speaker,ct.start_at,ct.end_at,ct.final_text,ct.interrupted FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id=$1::uuid ORDER BY ct.sequence_no LIMIT 1000`, resourceID) if err != nil { return shared, err } defer rows.Close() shared.Turns = []SharedTurn{} for rows.Next() { var turn SharedTurn if err := rows.Scan(&turn.SequenceNo, &turn.Speaker, &turn.StartAt, &turn.EndAt, &turn.Text, &turn.Interrupted); err != nil { return shared, err } shared.Turns = append(shared.Turns, turn) } return shared, rows.Err() case "RECORDING": var objectRef, contentType, track, conversationPublicID string var sequence int var consent bool err := s.pool.QueryRow(ctx, `SELECT ms.object_ref,ms.content_type,ms.track,ms.sequence_no,c.public_id::text,EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=ms.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='raw_audio_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())) FROM media_segment ms JOIN conversation c ON c.id=ms.conversation_id WHERE ms.public_id=$1::uuid AND ms.status='VERIFIED'`, resourceID).Scan(&objectRef, &contentType, &track, &sequence, &conversationPublicID, &consent) if err != nil { return shared, err } if !consent { return shared, ErrAccessDenied } extension := ".bin" if contentType == "audio/wav" { extension = ".wav" } else if contentType == "audio/opus" { extension = ".opus" } else if contentType == "audio/ogg" { extension = ".ogg" } until := time.Now().UTC().Add(s.accessTTL) shared.AccessURL, err = s.store.PresignMediaDownload(ctx, objectRef, s.accessTTL, fmt.Sprintf("%s-%s-%08d%s", conversationPublicID, track, sequence, extension), contentType, permission == "LISTEN") shared.AccessUntil = &until return shared, err default: return shared, ErrInvalid } } func (s *Service) EditStory(ctx context.Context, principal identity.Principal, grantPublicID, content, reason, requestID string, expectedVersion int64) (SharedResource, error) { content = strings.TrimSpace(content) reason = strings.TrimSpace(reason) if content == "" || len([]rune(content)) > 50000 || reason == "" || len([]rune(reason)) > 500 || expectedVersion < 1 { return SharedResource{}, ErrInvalid } tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return SharedResource{}, err } defer tx.Rollback(context.Background()) var callSubject string err = tx.QueryRow(ctx, `SELECT sd.subject_id::text FROM sharing_grant sg JOIN user_account ua ON ua.id=sg.grantee_user_id AND ua.tenant_id=sg.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' JOIN story_draft sd ON sd.public_id=sg.resource_public_id AND sd.tenant_id=sg.tenant_id WHERE sg.public_id=$1::uuid AND sg.resource_type='STORY' AND sg.permission='EDIT' AND sg.revoked_at IS NULL AND sg.expires_at>now() AND sd.call_session_id IS NOT NULL`, grantPublicID, principal.Issuer, principal.Subject).Scan(&callSubject) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return SharedResource{}, err } if callSubject != "" { var epoch int64 if err = tx.QueryRow(ctx, `SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$1::uuid FOR UPDATE`, callSubject).Scan(&epoch); err != nil { return SharedResource{}, err } } var grantID, tenantID, userID, userPublicID, resourceID, permission, storyID, currentRevisionID, title string var active bool var version, revisionNo int64 err = tx.QueryRow(ctx, `SELECT sg.id::text,sg.tenant_id::text,ua.id::text,ua.public_id::text,sg.resource_public_id::text,sg.permission,(sg.revoked_at IS NULL AND sg.expires_at>now()),sd.id::text,sd.current_revision_id::text,sd.title,sd.version,sr.revision_no FROM sharing_grant sg JOIN user_account ua ON ua.id=sg.grantee_user_id AND ua.tenant_id=sg.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' JOIN story_draft sd ON sd.public_id=sg.resource_public_id AND sd.tenant_id=sg.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id WHERE sg.public_id=$1::uuid AND sg.resource_type='STORY' AND call_story_available(sd.id) FOR UPDATE OF sg,sd`, grantPublicID, principal.Issuer, principal.Subject).Scan(&grantID, &tenantID, &userID, &userPublicID, &resourceID, &permission, &active, &storyID, ¤tRevisionID, &title, &version, &revisionNo) if errors.Is(err, pgx.ErrNoRows) { return SharedResource{}, ErrAccessDenied } if err != nil { return SharedResource{}, err } if !active { return SharedResource{}, ErrExpired } if permission != "EDIT" { return SharedResource{}, ErrAccessDenied } if version != expectedVersion { return SharedResource{}, ErrVersionConflict } var available bool if err = tx.QueryRow(ctx, `SELECT call_story_available(id) AND status NOT IN ('REJECTED','SUPERSEDED') FROM story_draft WHERE id=$1::uuid`, storyID).Scan(&available); err != nil { return SharedResource{}, err } if !available { return SharedResource{}, ErrAccessDenied } var newRevisionID string err = tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,author_id,edit_reason,base_revision_id) VALUES($1::uuid,$2,$3,'user',$4::uuid,$5,$6::uuid) RETURNING id::text`, storyID, revisionNo+1, content, userID, reason, currentRevisionID).Scan(&newRevisionID) if err != nil { return SharedResource{}, err } if _, err = tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, newRevisionID, currentRevisionID); err != nil { return SharedResource{}, err } if err = tx.QueryRow(ctx, `UPDATE story_draft SET current_revision_id=$2::uuid,status='IN_REVIEW',reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=version+1,updated_at=now() WHERE id=$1::uuid RETURNING version`, storyID, newRevisionID, userID, reason).Scan(&version); err != nil { return SharedResource{}, err } digestBytes := sha256.Sum256([]byte(principal.Issuer + "|" + principal.Subject)) actorDigest := hex.EncodeToString(digestBytes[:]) if _, err = tx.Exec(ctx, `INSERT INTO sharing_access_log(tenant_id,sharing_grant_id,actor_user_id,actor_identifier_digest,resource_type,resource_public_id,permission,result,request_id) VALUES($1::uuid,$2::uuid,$3::uuid,$4,'STORY',$5::uuid,'EDIT','SUCCESS',$6)`, tenantID, grantID, userID, actorDigest, resourceID, requestID); err != nil { return SharedResource{}, err } if err = s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "user", ActorPublicID: &userPublicID, Action: "sharing.story.edit", ResourceType: "story", ResourceID: &resourceID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"sharing_grant_id": grantPublicID, "revision_no": revisionNo + 1}}); err != nil { return SharedResource{}, err } if err = tx.Commit(ctx); err != nil { return SharedResource{}, err } return SharedResource{GrantID: grantPublicID, ResourceType: "STORY", ResourceID: resourceID, Permission: "EDIT", Title: title, Text: content, Version: version, RevisionNo: revisionNo + 1}, nil } func (s *Service) logAccess(ctx context.Context, tenantID, grantID, userID, actorDigest, resourceType, resourceID, permission, result, reason, requestID string) error { var actor any if userID != "" { actor = userID } _, err := s.pool.Exec(ctx, `INSERT INTO sharing_access_log(tenant_id,sharing_grant_id,actor_user_id,actor_identifier_digest,resource_type,resource_public_id,permission,result,reason_code,request_id) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6::uuid,$7,$8,NULLIF($9,''),$10)`, tenantID, grantID, actor, actorDigest, resourceType, resourceID, permission, result, reason, requestID) return err } type queryer interface { QueryRow(context.Context, string, ...any) pgx.Row } func (s *Service) resolveGrantor(ctx context.Context, q queryer, principal identity.Principal, resourceType, resourcePublicID string, lock bool) (resourceBoundary, string, string, error) { resourceTable := "" joins := "" subjectExpr := "" householdExpr := "" tenantExpr := "" switch resourceType { case "STORY": resourceTable = "story_draft resource" joins = "JOIN subject_profile sp ON sp.id=resource.subject_id AND sp.tenant_id=resource.tenant_id" subjectExpr = "resource.subject_id" householdExpr = "sp.household_id" tenantExpr = "resource.tenant_id" case "TRANSCRIPT": resourceTable = "conversation resource" joins = "JOIN subject_profile sp ON sp.id=resource.subject_id AND sp.tenant_id=resource.tenant_id" subjectExpr = "resource.subject_id" householdExpr = "resource.household_id" tenantExpr = "resource.tenant_id" case "RECORDING": resourceTable = "media_segment resource" joins = "JOIN conversation c ON c.id=resource.conversation_id AND c.tenant_id=resource.tenant_id JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id" subjectExpr = "c.subject_id" householdExpr = "c.household_id" tenantExpr = "resource.tenant_id" default: return resourceBoundary{}, "", "", ErrInvalid } lockClause := "" if resourceType == "STORY" { lockClause = " AND call_story_available(resource.id)" } if lock { lockClause += " FOR SHARE OF resource" } query := fmt.Sprintf(`SELECT %s::text,%s::text,%s::text,sp.public_id::text,ua.id::text,ua.public_id::text,hm.role FROM %s %s JOIN household_member hm ON hm.tenant_id=%s AND hm.household_id=%s AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=%s AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' WHERE resource.public_id=$1::uuid%s`, tenantExpr, householdExpr, subjectExpr, resourceTable, joins, tenantExpr, householdExpr, tenantExpr, lockClause) var b resourceBoundary var actorID, actorPublicID, role string err := q.QueryRow(ctx, query, resourcePublicID, principal.Issuer, principal.Subject).Scan(&b.tenantID, &b.householdID, &b.subjectID, &b.subjectPublicID, &actorID, &actorPublicID, &role) if errors.Is(err, pgx.ErrNoRows) { return b, "", "", ErrNotFound } if err != nil { return b, "", "", err } if role != "owner" && role != "admin" { return b, "", "", ErrAccessDenied } return b, actorID, actorPublicID, nil }