chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type sharingIntegrationStore struct{}
|
||||
|
||||
func (*sharingIntegrationStore) PresignMediaDownload(context.Context, string, time.Duration, string, string, bool) (string, error) {
|
||||
return "https://objects.invalid/shared-media", nil
|
||||
}
|
||||
|
||||
func TestTranscriptSharingLoginAndAnonymousLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if _, err := NewService(nil, audit.NewRepository(), &sharingIntegrationStore{}, []byte(strings.Repeat("s", 32)), time.Minute); err == nil {
|
||||
t.Fatal("nil database pool was accepted")
|
||||
}
|
||||
service, err := NewService(pool, audit.NewRepository(), &sharingIntegrationStore{}, []byte(strings.Repeat("s", 32)), 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
editor := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||||
outsider := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
|
||||
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
|
||||
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||||
loginKey, anonymousKey := "sharing-integration-login-"+uuid.NewString(), "sharing-integration-anonymous-"+uuid.NewString()
|
||||
grantIDs := []string{}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'sharing-integration-%'`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE scope='sharing-grant.create' AND idempotency_key=ANY($1::text[])`, []string{loginKey, anonymousKey})
|
||||
if len(grantIDs) > 0 {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM sharing_grant WHERE public_id=ANY($1::uuid[])`, grantIDs)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
|
||||
}()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',1,
|
||||
'10000000-0000-0000-0000-000000000701','[]','sharing-integration-v1','s2s_hybrid',$3,'合成分享上下文')`,
|
||||
contextID, contextPublicID, strings.Repeat("f", 64)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,
|
||||
provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',
|
||||
'10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501',
|
||||
'10000000-0000-0000-0000-000000000701',$3::uuid,$4,'s2s_hybrid','transcript_only','COMPLETED',now(),now()-interval '2 minutes',now(),'user_hangup')`,
|
||||
conversationID, conversationPublicID, contextID, "sharing-task-"+conversationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
|
||||
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '90 seconds',now()-interval '80 seconds',$3,'provider')`,
|
||||
conversationID, "sharing-turn-"+conversationID, "这是可分享的合成话轮。"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := normalizeInput(CreateInput{ResourceType: "story", ResourceID: uuid.NewString(), GranteeUserID: "10000000-0000-0000-0000-000000000112", Permission: "edit", Purpose: "禁止匿名编辑", ExpiresAt: time.Now().Add(time.Hour), LoginRequired: false}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("anonymous edit validation error=%v", err)
|
||||
}
|
||||
|
||||
loginInput := CreateInput{ResourceType: " transcript ", ResourceID: conversationPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112", Permission: " view_text ", Purpose: "家庭回顾", ExpiresAt: time.Now().Add(time.Hour), LoginRequired: true}
|
||||
loginGrant, created, err := service.Create(ctx, owner, loginKey, "sharing-integration-login-create", loginInput)
|
||||
if err != nil || !created || loginGrant.AccessToken != "" || loginGrant.ResourceType != "TRANSCRIPT" || loginGrant.Permission != "VIEW_TEXT" {
|
||||
t.Fatalf("login grant=%+v created=%v err=%v", loginGrant, created, err)
|
||||
}
|
||||
grantIDs = append(grantIDs, loginGrant.ID)
|
||||
replayedGrant, created, err := service.Create(ctx, owner, loginKey, "sharing-integration-login-replay", loginInput)
|
||||
if err != nil || created || replayedGrant.ID != loginGrant.ID {
|
||||
t.Fatalf("replayed grant=%+v created=%v err=%v", replayedGrant, created, err)
|
||||
}
|
||||
grants, err := service.List(ctx, owner, "10000000-0000-0000-0000-000000000411")
|
||||
if err != nil || len(grants) != 1 || grants[0].ID != loginGrant.ID {
|
||||
t.Fatalf("grants=%+v err=%v", grants, err)
|
||||
}
|
||||
shared, err := service.Access(ctx, editor, loginGrant.ID, "sharing-integration-login-access")
|
||||
if err != nil || len(shared.Turns) != 1 || shared.Turns[0].Text != "这是可分享的合成话轮。" {
|
||||
t.Fatalf("shared transcript=%+v err=%v", shared, err)
|
||||
}
|
||||
if _, err := service.Access(ctx, owner, loginGrant.ID, "sharing-integration-recipient-mismatch"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("grantor recipient mismatch error=%v", err)
|
||||
}
|
||||
if _, err := service.Access(ctx, outsider, loginGrant.ID, "sharing-integration-outsider"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("outsider access error=%v", err)
|
||||
}
|
||||
if _, err := service.AccessByToken(ctx, loginGrant.ID, strings.Repeat("x", 40), "sharing-integration-login-token"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("login grant token error=%v", err)
|
||||
}
|
||||
|
||||
anonymousInput := loginInput
|
||||
anonymousInput.LoginRequired = false
|
||||
anonymousInput.Purpose = "匿名家庭回顾"
|
||||
anonymousGrant, created, err := service.Create(ctx, owner, anonymousKey, "sharing-integration-anonymous-create", anonymousInput)
|
||||
if err != nil || !created || len(anonymousGrant.AccessToken) < 32 {
|
||||
t.Fatalf("anonymous grant=%+v created=%v err=%v", anonymousGrant, created, err)
|
||||
}
|
||||
grantIDs = append(grantIDs, anonymousGrant.ID)
|
||||
if _, err := service.AccessByToken(ctx, anonymousGrant.ID, "wrong-token-value-that-is-long-enough", "sharing-integration-bad-token"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("bad anonymous token error=%v", err)
|
||||
}
|
||||
shared, err = service.AccessByToken(ctx, anonymousGrant.ID, anonymousGrant.AccessToken, "sharing-integration-token-access")
|
||||
if err != nil || len(shared.Turns) != 1 {
|
||||
t.Fatalf("anonymous shared transcript=%+v err=%v", shared, err)
|
||||
}
|
||||
revoked, err := service.Revoke(ctx, owner, anonymousGrant.ID, "sharing-integration-revoke")
|
||||
if err != nil || revoked.RevokedAt == nil || revoked.Version != 2 {
|
||||
t.Fatalf("revoked grant=%+v err=%v", revoked, err)
|
||||
}
|
||||
if _, err := service.AccessByToken(ctx, anonymousGrant.ID, anonymousGrant.AccessToken, "sharing-integration-revoked-token"); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("revoked token error=%v", err)
|
||||
}
|
||||
var successLogs, deniedLogs int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FILTER(WHERE result='SUCCESS'),count(*) FILTER(WHERE result='DENIED') FROM sharing_access_log WHERE sharing_grant_id IN (SELECT id FROM sharing_grant WHERE public_id=ANY($1::uuid[]))`, grantIDs).Scan(&successLogs, &deniedLogs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if successLogs != 2 || deniedLogs != 5 {
|
||||
t.Fatalf("sharing access logs success=%d denied=%d", successLogs, deniedLogs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestStorySharingReadEditVersionAndRevocationLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
service, err := NewService(pool, audit.NewRepository(), &sharingIntegrationStore{}, []byte(strings.Repeat("t", 32)), 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
editor := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||||
outsider := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
|
||||
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
|
||||
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||||
storyID, storyPublicID, revisionID := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
mediaID, mediaPublicID, consentID, consentPublicID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
editKey := "sharing-story-edit-" + uuid.NewString()
|
||||
viewKey := "sharing-story-view-" + uuid.NewString()
|
||||
recordingListenKey := "sharing-story-recording-listen-" + uuid.NewString()
|
||||
recordingDownloadKey := "sharing-story-recording-download-" + uuid.NewString()
|
||||
requestPrefix := "sharing-story-"
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%")
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE scope='sharing-grant.create' AND idempotency_key=ANY($1::text[])`, []string{editKey, viewKey, recordingListenKey, recordingDownloadKey})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM sharing_grant WHERE resource_type='STORY' AND resource_public_id=$1::uuid`, storyPublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM sharing_grant WHERE resource_type='RECORDING' AND resource_public_id=$1::uuid`, mediaPublicID)
|
||||
_, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE id=$1::uuid`, storyID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id=$1::uuid)`, storyID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM story_revision WHERE story_id=$1::uuid`, storyID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM story_draft WHERE id=$1::uuid`, storyID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_segment WHERE id=$1::uuid`, mediaID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM consent_record WHERE id=$1::uuid`, consentID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
|
||||
}()
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',1,
|
||||
'10000000-0000-0000-0000-000000000701','[]','sharing-story-v1','s2s_hybrid',$3,'合成故事分享上下文')`,
|
||||
contextID, contextPublicID, "a"+strings.Repeat("1", 63)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,
|
||||
provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',
|
||||
'10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501',
|
||||
'10000000-0000-0000-0000-000000000701',$3::uuid,$4,'s2s_hybrid','transcript_only','COMPLETED',now(),now()-interval '2 minutes',now(),'user_hangup')`,
|
||||
conversationID, conversationPublicID, contextID, "sharing-story-task-"+conversationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO story_draft(id,public_id,tenant_id,subject_id,conversation_id,title,status,confidence,sensitivity,generation_version)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$3::uuid,'合成教师故事','CANDIDATE',0.9,'normal',1)`,
|
||||
storyID, storyPublicID, conversationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO story_revision(id,story_id,revision_no,content,author_type,model_ref,prompt_version) VALUES($1::uuid,$2::uuid,1,$3,'model','sharing-story-model','v1')`, revisionID, storyID, "初始合成故事内容。"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=$1::uuid WHERE id=$2::uuid`, revisionID, storyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO consent_record(id,public_id,tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',
|
||||
'raw_audio_storage','{}'::jsonb,'sharing-story-v1',repeat('b',64),'10000000-0000-0000-0000-000000000101')`, consentID, consentPublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_segment(id,public_id,tenant_id,conversation_id,device_id,track,sequence_no,codec,content_type,duration_ms,
|
||||
expected_size,expected_digest,object_ref,status,uploaded_at,verified_at)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,'10000000-0000-0000-0000-000000000501',
|
||||
'near',7,'wav_pcm_s16le','audio/wav',1000,32044,repeat('c',64),$4,'VERIFIED',now(),now())`,
|
||||
mediaID, mediaPublicID, conversationID, "sharing/story/recording/"+mediaID+".wav"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().UTC().Add(time.Hour)
|
||||
editGrant, created, err := service.Create(ctx, owner, editKey, requestPrefix+"edit-create", CreateInput{
|
||||
ResourceType: "story", ResourceID: storyPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112",
|
||||
Permission: "edit", Purpose: "共同核对故事", ExpiresAt: expiresAt, LoginRequired: true,
|
||||
})
|
||||
if err != nil || !created || editGrant.Permission != "EDIT" || !editGrant.LoginRequired {
|
||||
t.Fatalf("edit grant=%+v created=%t err=%v", editGrant, created, err)
|
||||
}
|
||||
shared, err := service.Access(ctx, editor, editGrant.ID, requestPrefix+"edit-access")
|
||||
if err != nil || shared.Title != "合成教师故事" || shared.Text != "初始合成故事内容。" || shared.Version != 1 || shared.RevisionNo != 1 {
|
||||
t.Fatalf("shared story=%+v err=%v", shared, err)
|
||||
}
|
||||
if _, err := service.Access(ctx, outsider, editGrant.ID, requestPrefix+"outsider-access"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("outsider access error=%v", err)
|
||||
}
|
||||
if _, err := service.EditStory(ctx, editor, editGrant.ID, "", "缺少正文", requestPrefix+"invalid-edit", 1); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid edit error=%v", err)
|
||||
}
|
||||
if _, err := service.EditStory(ctx, outsider, editGrant.ID, "越权内容", "越权修改", requestPrefix+"outsider-edit", 1); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("outsider edit error=%v", err)
|
||||
}
|
||||
if _, err := service.EditStory(ctx, editor, editGrant.ID, "错误版本内容", "版本冲突", requestPrefix+"stale-edit", 99); !errors.Is(err, ErrVersionConflict) {
|
||||
t.Fatalf("stale edit error=%v", err)
|
||||
}
|
||||
updatedText := "家庭成员核对后的合成故事内容。"
|
||||
updated, err := service.EditStory(ctx, editor, editGrant.ID, updatedText, "根据本人陈述修订", requestPrefix+"edit-success", 1)
|
||||
if err != nil || updated.Text != updatedText || updated.Version != 2 || updated.RevisionNo != 2 || updated.Permission != "EDIT" {
|
||||
t.Fatalf("updated story=%+v err=%v", updated, err)
|
||||
}
|
||||
shared, err = service.Access(ctx, editor, editGrant.ID, requestPrefix+"edit-access-updated")
|
||||
if err != nil || shared.Text != updatedText || shared.Version != 2 || shared.RevisionNo != 2 {
|
||||
t.Fatalf("updated shared story=%+v err=%v", shared, err)
|
||||
}
|
||||
|
||||
viewGrant, created, err := service.Create(ctx, owner, viewKey, requestPrefix+"view-create", CreateInput{
|
||||
ResourceType: "STORY", ResourceID: storyPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112",
|
||||
Permission: "VIEW_TEXT", Purpose: "匿名查看已核对故事", ExpiresAt: expiresAt, LoginRequired: false,
|
||||
})
|
||||
if err != nil || !created || len(viewGrant.AccessToken) < 32 {
|
||||
t.Fatalf("view grant=%+v created=%t err=%v", viewGrant, created, err)
|
||||
}
|
||||
shared, err = service.AccessByToken(ctx, viewGrant.ID, viewGrant.AccessToken, requestPrefix+"token-access")
|
||||
if err != nil || shared.Text != updatedText || shared.Permission != "VIEW_TEXT" {
|
||||
t.Fatalf("token story=%+v err=%v", shared, err)
|
||||
}
|
||||
if _, err := service.EditStory(ctx, editor, viewGrant.ID, "禁止修改", "只有查看权限", requestPrefix+"view-edit", 2); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("view grant edit error=%v", err)
|
||||
}
|
||||
if _, err := service.AccessByToken(ctx, uuid.NewString(), viewGrant.AccessToken, requestPrefix+"missing-token-grant"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing token grant error=%v", err)
|
||||
}
|
||||
if _, err := service.Revoke(ctx, owner, editGrant.ID, requestPrefix+"edit-revoke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.EditStory(ctx, editor, editGrant.ID, "撤销后修改", "应被拒绝", requestPrefix+"revoked-edit", 2); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("revoked edit error=%v", err)
|
||||
}
|
||||
if _, err := service.Access(ctx, editor, editGrant.ID, requestPrefix+"revoked-access"); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("revoked access error=%v", err)
|
||||
}
|
||||
|
||||
recordingListenGrant, created, err := service.Create(ctx, owner, recordingListenKey, requestPrefix+"recording-listen-create", CreateInput{
|
||||
ResourceType: "RECORDING", ResourceID: mediaPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112",
|
||||
Permission: "LISTEN", Purpose: "登录后试听录音", ExpiresAt: expiresAt, LoginRequired: true,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("recording listen grant=%+v created=%t err=%v", recordingListenGrant, created, err)
|
||||
}
|
||||
recording, err := service.Access(ctx, editor, recordingListenGrant.ID, requestPrefix+"recording-listen-access")
|
||||
if err != nil || recording.AccessURL != "https://objects.invalid/shared-media" || recording.AccessUntil == nil || recording.Permission != "LISTEN" {
|
||||
t.Fatalf("recording listen=%+v err=%v", recording, err)
|
||||
}
|
||||
recordingDownloadGrant, created, err := service.Create(ctx, owner, recordingDownloadKey, requestPrefix+"recording-download-create", CreateInput{
|
||||
ResourceType: "RECORDING", ResourceID: mediaPublicID, GranteeUserID: "10000000-0000-0000-0000-000000000112",
|
||||
Permission: "DOWNLOAD", Purpose: "匿名下载录音", ExpiresAt: expiresAt, LoginRequired: false,
|
||||
})
|
||||
if err != nil || !created || len(recordingDownloadGrant.AccessToken) < 32 {
|
||||
t.Fatalf("recording download grant=%+v created=%t err=%v", recordingDownloadGrant, created, err)
|
||||
}
|
||||
recording, err = service.AccessByToken(ctx, recordingDownloadGrant.ID, recordingDownloadGrant.AccessToken, requestPrefix+"recording-download-access")
|
||||
if err != nil || recording.AccessURL != "https://objects.invalid/shared-media" || recording.AccessUntil == nil || recording.Permission != "DOWNLOAD" {
|
||||
t.Fatalf("recording download=%+v err=%v", recording, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE consent_record SET withdrawn_at=now(),updated_at=now() WHERE id=$1::uuid`, consentID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Access(ctx, editor, recordingListenGrant.ID, requestPrefix+"recording-listen-withdrawn"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("withdrawn recording listen error=%v", err)
|
||||
}
|
||||
if _, err := service.AccessByToken(ctx, recordingDownloadGrant.ID, recordingDownloadGrant.AccessToken, requestPrefix+"recording-download-withdrawn"); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("withdrawn recording download error=%v", err)
|
||||
}
|
||||
if _, err := service.loadResource(ctx, uuid.NewString(), "UNKNOWN", uuid.NewString(), "VIEW_TEXT"); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("unknown resource type error=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user