546 lines
30 KiB
Go
546 lines
30 KiB
Go
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'`, 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'`, 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 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' 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 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,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_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 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
|
|
}
|