chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+545
View File
@@ -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, &currentRevisionID, &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)
}
}
+198
View File
@@ -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)
}
}