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
@@ -0,0 +1,81 @@
package saas
import (
"context"
"os"
"testing"
"data-terminal-cloud/internal/audit"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestConsentWithdrawalCreatesReliableConversationStop(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()
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
consentSnapshotID := uuid.NewString()
contextSnapshotID := uuid.NewString()
conversationID := uuid.NewString()
conversationPublicID := uuid.NewString()
taskRef := "consent-stop-test-" + uuid.NewString()
if _, err := tx.Exec(ctx, `
INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
VALUES($1::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'{"voice_chat":true,"story_extraction":true}'::jsonb,'[]'::jsonb)`, consentSnapshotID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest)
VALUES($2::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,$1::uuid,'consent-stop-test','s2s_hybrid',repeat('8',64))`, consentSnapshotID, contextSnapshotID); err != nil {
t.Fatal(err)
}
if _, err := tx.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,lease_expires_at)
VALUES($3::uuid,$4::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000502'::uuid,$1::uuid,$2::uuid,$5,'s2s_hybrid','transcript_only','ACTIVE',now()+interval '5 minutes',now(),now()+interval '30 seconds')`, consentSnapshotID, contextSnapshotID, conversationID, conversationPublicID, taskRef); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status,lease_expires_at)
VALUES($1::uuid,'mock',$2,$3,'consent-stop-device','consent-stop-agent','ACTIVE',now()+interval '30 seconds')`,
conversationID, taskRef, "consent-stop-room-"+uuid.NewString()); err != nil {
t.Fatal(err)
}
service := NewService(pool, audit.NewRepository(), nil, nil)
affected, err := service.stopConversationsAfterConsentWithdrawal(ctx, tx,
"10000000-0000-0000-0000-000000000001", "10000000-0000-0000-0000-000000000401", ConsentStoryExtraction)
if err != nil {
t.Fatal(err)
}
if len(affected) != 1 || affected[0] != conversationPublicID {
t.Fatalf("affected=%v", affected)
}
var state, stopReason, rtcState string
var historyCount, outboxCount int
if err := tx.QueryRow(ctx, `
SELECT c.status,c.stop_reason,rt.status,
(SELECT count(*) FROM conversation_state_history h WHERE h.conversation_id=c.id AND h.to_status='STOPPING'),
(SELECT count(*) FROM outbox_event o WHERE o.aggregate_id=c.id AND o.event_type='conversation.agent_stop_requested')
FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.id=$1::uuid`, conversationID).
Scan(&state, &stopReason, &rtcState, &historyCount, &outboxCount); err != nil {
t.Fatal(err)
}
if state != "STOPPING" || stopReason != "consent_withdrawn:story_extraction" || rtcState != "STOPPING" || historyCount != 1 || outboxCount != 1 {
t.Fatalf("state=%s reason=%s rtc=%s history=%d outbox=%d", state, stopReason, rtcState, historyCount, outboxCount)
}
}
+19
View File
@@ -0,0 +1,19 @@
package saas
import "testing"
func TestConsentWithdrawalConversationImpact(t *testing.T) {
for _, consentType := range []string{
ConsentTranscriptStorage,
ConsentRawAudioStorage,
ConsentStoryExtraction,
ConsentPersonalMemory,
} {
if !consentWithdrawalStopsConversation(consentType) {
t.Fatalf("live conversation consent %q did not trigger a stop", consentType)
}
}
if consentWithdrawalStopsConversation(ConsentFamilySharing) {
t.Fatal("family sharing withdrawal should not interrupt live audio")
}
}
+674
View File
@@ -0,0 +1,674 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
)
var (
ErrStorySourceRequired = errors.New("story source is required")
ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required")
ErrReviewStateConflict = errors.New("content review state does not allow this operation")
)
type ConversationTurn struct {
ID string `json:"id"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
FinalText string `json:"final_text"`
Interrupted bool `json:"interrupted"`
SourceType string `json:"source_type"`
}
type ConversationTurnPage struct {
Items []ConversationTurn `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ConversationContentSummary struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
SourceTurnIDs []string `json:"source_turn_ids"`
ModelRef string `json:"model_ref"`
PromptVersion string `json:"prompt_version"`
GenerationVersion int64 `json:"generation_version"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type StorySource struct {
TurnID string `json:"turn_id"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
Text string `json:"text"`
StartOffset *int `json:"start_offset,omitempty"`
EndOffset *int `json:"end_offset,omitempty"`
}
type FactCandidate struct {
ID string `json:"id"`
FactType string `json:"fact_type"`
Content string `json:"content"`
StatementBasis string `json:"statement_basis"`
Confidence *float64 `json:"confidence,omitempty"`
Sensitivity string `json:"sensitivity"`
SourceTurnIDs []string `json:"source_turn_ids"`
Status string `json:"status"`
Version int64 `json:"version"`
}
type Story struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
Confidence *float64 `json:"confidence,omitempty"`
Sensitivity string `json:"sensitivity"`
GenerationVersion int64 `json:"generation_version"`
RevisionNo int64 `json:"revision_no"`
AuthorType string `json:"author_type"`
ReviewReason string `json:"review_reason,omitempty"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Sources []StorySource `json:"sources"`
Facts []FactCandidate `json:"facts"`
}
type CuratedMemory struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Content string `json:"content"`
SourceRefs []map[string]any `json:"source_refs"`
Status string `json:"status"`
Sensitivity string `json:"sensitivity"`
VersionNo int64 `json:"version_no"`
Version int64 `json:"version"`
ConfirmedBy string `json:"confirmed_by"`
ConfirmedAt time.Time `json:"confirmed_at"`
ChangeReason string `json:"change_reason,omitempty"`
IndexStatus string `json:"index_status"`
LastIndexError string `json:"last_index_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UpdateMemoryInput struct {
Content *string
Status *string
Reason string
}
func (s *Service) ListConversationTurns(ctx context.Context, principal identity.Principal, conversationPublicID string, limit int, cursor string) (ConversationTurnPage, error) {
access, conversationID, err := s.resolveConversationContent(ctx, s.pool, principal, conversationPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return ConversationTurnPage{}, err
}
if s.cursor == nil {
return ConversationTurnPage{}, errors.New("pagination cursor codec is unavailable")
}
var afterSequence int64
var afterID string
if cursor != "" {
decoded, err := s.cursor.Decode(cursor)
if err != nil {
return ConversationTurnPage{}, pagination.ErrInvalidCursor
}
if _, err := fmt.Sscan(decoded.SortKey, &afterSequence); err != nil {
return ConversationTurnPage{}, pagination.ErrInvalidCursor
}
afterID = decoded.ID
}
rows, err := s.pool.Query(ctx, `
SELECT public_id::text,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type
FROM conversation_turn WHERE tenant_id=$1::uuid AND conversation_id=$2::uuid
AND ($3::bigint=0 OR (sequence_no,public_id)>($3,$4::uuid))
ORDER BY sequence_no,public_id LIMIT $5`, access.tenantID, conversationID, afterSequence, nullUUID(afterID), limit+1)
if err != nil {
return ConversationTurnPage{}, fmt.Errorf("list conversation turns: %w", err)
}
defer rows.Close()
items := make([]ConversationTurn, 0, limit+1)
for rows.Next() {
var item ConversationTurn
if err := rows.Scan(&item.ID, &item.SequenceNo, &item.Speaker, &item.StartAt, &item.EndAt, &item.FinalText, &item.Interrupted, &item.SourceType); err != nil {
return ConversationTurnPage{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return ConversationTurnPage{}, err
}
page := ConversationTurnPage{Items: items}
if len(items) > limit {
page.Items = items[:limit]
last := page.Items[len(page.Items)-1]
page.NextCursor, err = s.cursor.Encode(fmt.Sprint(last.SequenceNo), last.ID)
if err != nil {
return ConversationTurnPage{}, err
}
}
return page, nil
}
func (s *Service) ListConversationContentSummaries(ctx context.Context, principal identity.Principal, conversationPublicID string) ([]ConversationContentSummary, error) {
access, conversationID, err := s.resolveConversationContent(ctx, s.pool, principal, conversationPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `SELECT public_id::text,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,status,created_at FROM conversation_summary WHERE tenant_id=$1::uuid AND conversation_id=$2::uuid ORDER BY generation_version DESC,summary_type`, access.tenantID, conversationID)
if err != nil {
return nil, fmt.Errorf("list conversation summaries: %w", err)
}
defer rows.Close()
items := make([]ConversationContentSummary, 0)
for rows.Next() {
var item ConversationContentSummary
var sourceJSON []byte
if err := rows.Scan(&item.ID, &item.Type, &item.Content, &sourceJSON, &item.ModelRef, &item.PromptVersion, &item.GenerationVersion, &item.Status, &item.CreatedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(sourceJSON, &item.SourceTurnIDs); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Principal, subjectPublicID, status string) ([]Story, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status)
if err != nil {
return nil, fmt.Errorf("list subject stories: %w", err)
}
defer rows.Close()
items := make([]Story, 0)
for rows.Next() {
item, err := scanStory(rows)
if err != nil {
return nil, err
}
item.Sources = []StorySource{}
item.Facts = []FactCandidate{}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetStory(ctx context.Context, principal identity.Principal, storyPublicID string) (Story, error) {
_, story, err := s.resolveStory(ctx, s.pool, principal, storyPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return Story{}, err
}
if err := s.loadStoryChildren(ctx, &story); err != nil {
return Story{}, err
}
return story, nil
}
func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal, storyPublicID, content, reason, requestID string, expectedVersion int64) (Story, error) {
var result Story
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" {
return ErrReviewStateConflict
}
var currentRevisionID, newRevisionID string
if err := tx.QueryRow(ctx, `SELECT current_revision_id::text FROM story_draft WHERE public_id=$1::uuid`, storyPublicID).Scan(&currentRevisionID); err != nil {
return err
}
if err := tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,author_id,edit_reason,base_revision_id) SELECT id,$2,$3,'user',$4::uuid,$5,$6::uuid FROM story_draft WHERE public_id=$1::uuid RETURNING id::text`, storyPublicID, current.RevisionNo+1, content, access.userID, reason, currentRevisionID).Scan(&newRevisionID); err != nil {
return fmt.Errorf("insert story revision: %w", 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 err
}
row := tx.QueryRow(ctx, storyUpdateReturningSQL, `IN_REVIEW`, newRevisionID, access.userID, reason, storyPublicID)
result, err = scanStory(row)
if err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.revise", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"revision_no": result.RevisionNo, "reason": reason}})
})
if err != nil {
return Story{}, err
}
if err := s.loadStoryChildren(ctx, &result); err != nil {
return Story{}, err
}
return result, nil
}
func (s *Service) RejectStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64) (Story, error) {
var result Story
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" {
return ErrReviewStateConflict
}
row := tx.QueryRow(ctx, storyStatusReturningSQL, "REJECTED", access.userID, reason, storyPublicID)
result, err = scanStory(row)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE fact_candidate SET status='REJECTED',reviewed_by=$1::uuid,reviewed_at=now(),review_reason=$2,version=version+1,updated_at=now() WHERE story_id=(SELECT id FROM story_draft WHERE public_id=$3::uuid) AND status='CANDIDATE'`, access.userID, reason, storyPublicID); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.reject", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"reason": reason}})
})
if err != nil {
return Story{}, err
}
if err := s.loadStoryChildren(ctx, &result); err != nil {
return Story{}, err
}
return result, nil
}
func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64) (Story, CuratedMemory, error) {
var story Story
var memory CuratedMemory
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" {
return ErrReviewStateConflict
}
var sourceCount int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM story_source WHERE story_revision_id=(SELECT current_revision_id FROM story_draft WHERE public_id=$1::uuid)`, storyPublicID).Scan(&sourceCount); err != nil {
return err
}
if sourceCount == 0 {
return ErrStorySourceRequired
}
var consent bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, access.tenantID, access.subjectID).Scan(&consent); err != nil {
return err
}
if !consent {
return ErrPersonalizedMemoryConsentNeeded
}
storyRow := tx.QueryRow(ctx, storyStatusReturningSQL, "CONFIRMED", access.userID, reason, storyPublicID)
story, err = scanStory(storyRow)
if err != nil {
return err
}
sourceRefs := []map[string]any{{"type": "story", "story_id": storyPublicID, "revision_no": story.RevisionNo}}
sourceJSON, _ := json.Marshal(sourceRefs)
digest := sha256.Sum256([]byte(story.Content))
var memoryID, memoryVersionID string
if err := tx.QueryRow(ctx, `INSERT INTO curated_memory(tenant_id,subject_id,status,sensitivity,valid_from) VALUES($1::uuid,$2::uuid,'ACTIVE',$3,now()) RETURNING id::text,public_id::text`, access.tenantID, access.subjectID, story.Sensitivity).Scan(&memoryID, &memory.ID); err != nil {
return fmt.Errorf("create curated memory: %w", err)
}
if err := tx.QueryRow(ctx, `INSERT INTO memory_version(memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest) VALUES($1::uuid,1,$2,$3::jsonb,$4::uuid,$5,$6) RETURNING id::text`, memoryID, story.Content, sourceJSON, access.userID, reason, hex.EncodeToString(digest[:])).Scan(&memoryVersionID); err != nil {
return fmt.Errorf("create memory version: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid WHERE id=$2::uuid`, memoryVersionID, memoryID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, access.tenantID, access.subjectID, memoryID, memoryVersionID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE fact_candidate SET status='CONFIRMED',reviewed_by=$1::uuid,reviewed_at=now(),review_reason=$2,version=version+1,updated_at=now() WHERE story_id=(SELECT id FROM story_draft WHERE public_id=$3::uuid) AND status='CANDIDATE'`, access.userID, reason, storyPublicID); err != nil {
return err
}
dedupe := "memory-index-v1:" + memoryVersionID
payload, _ := json.Marshal(map[string]any{"memory_version_id": memoryVersionID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.confirm", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"memory_id": memory.ID, "revision_no": story.RevisionNo}}); err != nil {
return err
}
return nil
})
if err != nil {
return Story{}, CuratedMemory{}, err
}
memory, err = s.GetMemory(ctx, principal, memory.ID)
if err != nil {
return Story{}, CuratedMemory{}, err
}
if err := s.loadStoryChildren(ctx, &story); err != nil {
return Story{}, CuratedMemory{}, err
}
return story, memory, nil
}
func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Principal, subjectPublicID, query string) ([]CuratedMemory, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, memorySelectSQL+` WHERE cm.tenant_id=$1::uuid AND cm.subject_id=$2::uuid AND cm.status<>'DELETED' AND (NULLIF($3,'') IS NULL OR strpos(lower(mv.content),lower($3))>0) ORDER BY cm.updated_at DESC,cm.public_id`, access.tenantID, access.subjectID, strings.TrimSpace(query))
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]CuratedMemory, 0)
for rows.Next() {
item, err := scanMemory(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, memoryPublicID string) (CuratedMemory, error) {
result, err := scanMemory(s.pool.QueryRow(ctx, memorySelectSQL+` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`, memoryPublicID, principal.Issuer, principal.Subject))
if errors.Is(err, pgx.ErrNoRows) {
return CuratedMemory{}, ErrAccessDenied
}
if err != nil {
return CuratedMemory{}, err
}
return result, nil
}
func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal, memoryPublicID, requestID string, expectedVersion int64, input UpdateMemoryInput) (CuratedMemory, error) {
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var internalID, tenantID, subjectID, userID, userPublicID, role string
var currentVersionID, currentContent, currentStatus string
var currentVersionNo, resourceVersion int64
var sourceRefs []byte
err := tx.QueryRow(ctx, `
SELECT cm.id::text,cm.tenant_id::text,cm.subject_id::text,member_account.id::text,
member_account.public_id::text,hm.role,cm.current_version_id::text,mv.version_no,
mv.content,mv.source_refs,cm.status,cm.version
FROM curated_memory cm
JOIN memory_version mv ON mv.id=cm.current_version_id
JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id
JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id
JOIN household_member hm ON hm.household_id=h.id AND hm.status='active'
JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active'
WHERE cm.public_id=$1::uuid AND cm.status<>'DELETED' AND sp.status IN ('active','suspended')
AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3
FOR UPDATE OF cm`, memoryPublicID, principal.Issuer, principal.Subject).Scan(
&internalID, &tenantID, &subjectID, &userID, &userPublicID, &role, &currentVersionID,
&currentVersionNo, &currentContent, &sourceRefs, &currentStatus, &resourceVersion)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
}
if err != nil {
return fmt.Errorf("resolve memory update: %w", err)
}
if !identity.Can(identity.Role(role), identity.CapabilityStoryReview) {
return ErrAccessDenied
}
if resourceVersion != expectedVersion {
return ErrVersionConflict
}
if currentStatus == "PENDING_DELETE" {
return ErrReviewStateConflict
}
targetContent := currentContent
if input.Content != nil {
targetContent = strings.TrimSpace(*input.Content)
}
targetStatus := currentStatus
if input.Status != nil {
targetStatus = *input.Status
}
if targetContent == currentContent && targetStatus == currentStatus {
return ErrReviewStateConflict
}
if targetStatus == "ACTIVE" {
var consent bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, tenantID, subjectID).Scan(&consent); err != nil {
return err
}
if !consent {
return ErrPersonalizedMemoryConsentNeeded
}
}
newVersionID := currentVersionID
if targetContent != currentContent {
digest := sha256.Sum256([]byte(targetContent))
if err := tx.QueryRow(ctx, `
INSERT INTO memory_version(memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest)
VALUES($1::uuid,$2,$3,$4::jsonb,$5::uuid,$6,$7) RETURNING id::text`,
internalID, currentVersionNo+1, targetContent, sourceRefs, userID, input.Reason, hex.EncodeToString(digest[:])).Scan(&newVersionID); err != nil {
return fmt.Errorf("create memory revision: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='memory version or status changed',updated_at=now() WHERE memory_id=$1::uuid AND status IN ('PENDING','ACTIVE')`, internalID); err != nil {
return err
}
if targetStatus == "ACTIVE" {
if newVersionID == currentVersionID {
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET status='PENDING',last_error=NULL,updated_at=now() WHERE memory_version_id=$1::uuid AND index_type='pgvector'`, newVersionID); err != nil {
return err
}
} else if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, tenantID, subjectID, internalID, newVersionID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid,status=$2,version=version+1,updated_at=now() WHERE id=$3::uuid`, newVersionID, targetStatus, internalID); err != nil {
return err
}
if targetStatus == "ACTIVE" {
dedupe := fmt.Sprintf("memory-index-update-v%d:%s", resourceVersion+1, newVersionID)
payload, _ := json.Marshal(map[string]any{"memory_version_id": newVersionID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID), Action: "memory.update", ResourceType: "memory", ResourceID: strPtr(memoryPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": currentStatus, "to_status": targetStatus, "content_revised": targetContent != currentContent, "reason": input.Reason}})
})
if err != nil {
return CuratedMemory{}, err
}
return s.GetMemory(ctx, principal, memoryPublicID)
}
func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.Principal, memoryPublicID, reason, requestID string, expectedVersion int64) (CuratedMemory, error) {
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var internalID, tenantID, userPublicID, role, status string
var version int64
err := tx.QueryRow(ctx, `
SELECT cm.id::text,cm.tenant_id::text,member_account.public_id::text,hm.role,cm.status,cm.version
FROM curated_memory cm
JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id
JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id
JOIN household_member hm ON hm.household_id=h.id AND hm.status='active'
JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active'
WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3
FOR UPDATE OF cm`, memoryPublicID, principal.Issuer, principal.Subject).Scan(&internalID, &tenantID, &userPublicID, &role, &status, &version)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
}
if err != nil {
return fmt.Errorf("resolve memory deletion: %w", err)
}
if !identity.Can(identity.Role(role), identity.CapabilityStoryReview) {
return ErrAccessDenied
}
if version != expectedVersion {
return ErrVersionConflict
}
if status == "DELETED" || status == "PENDING_DELETE" {
return nil
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET status='PENDING_DELETE',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='memory deletion requested',updated_at=now() WHERE memory_id=$1::uuid AND status<>'DELETED'`, internalID); err != nil {
return err
}
dedupe := "memory-delete:" + internalID
payload, _ := json.Marshal(map[string]any{"reason": reason})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.delete_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID), Action: "memory.delete.request", ResourceType: "memory", ResourceID: strPtr(memoryPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"reason": reason}})
})
if err != nil {
return CuratedMemory{}, err
}
return s.GetMemory(ctx, principal, memoryPublicID)
}
type contentAccess struct {
tenantID, subjectID, userID, userPublicID string
role identity.Role
}
func (s *Service) resolveConversationContent(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, string, error) {
sql := `SELECT c.tenant_id::text,c.subject_id::text,ua.id::text,ua.public_id::text,hm.role,c.id::text FROM conversation c JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended') JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active' WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF c`
}
var a contentAccess
var role, conversationID string
err := q.QueryRow(ctx, sql, publicID, p.Issuer, p.Subject).Scan(&a.tenantID, &a.subjectID, &a.userID, &a.userPublicID, &role, &conversationID)
if errors.Is(err, pgx.ErrNoRows) {
return contentAccess{}, "", ErrAccessDenied
}
if err != nil {
return contentAccess{}, "", err
}
a.role = identity.Role(role)
if !identity.Can(a.role, cap) {
return contentAccess{}, "", ErrAccessDenied
}
return a, conversationID, nil
}
func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, Story, error) {
sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF sd`
}
var a contentAccess
var story Story
var role, tenantID, subjectInternal string
targets := storyScanTargets(&story, &tenantID, &subjectInternal)
targets = append(targets, &a.userID, &a.userPublicID, &role)
row := q.QueryRow(ctx, strings.Replace(sql, `sd.subject_id::text AS subject_internal_id`, `sd.subject_id::text AS subject_internal_id,member_account.id::text,member_account.public_id::text,hm.role`, 1), publicID, p.Issuer, p.Subject)
err := row.Scan(targets...)
if errors.Is(err, pgx.ErrNoRows) {
return contentAccess{}, Story{}, ErrAccessDenied
}
if err != nil {
return contentAccess{}, Story{}, err
}
a.tenantID = tenantID
a.subjectID = subjectInternal
a.role = identity.Role(role)
if !identity.Can(a.role, cap) {
return contentAccess{}, Story{}, ErrAccessDenied
}
return a, story, nil
}
const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,c.public_id::text AS conversation_public_id,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id`
const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=sd.current_revision_id RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=$2::uuid RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
func storyScanTargets(s *Story, tenantID, subjectInternal *string) []any {
return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal}
}
func scanStory(row rowScanner) (Story, error) {
var s Story
var tenant, subject string
err := row.Scan(storyScanTargets(&s, &tenant, &subject)...)
return s, err
}
func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
rows, err := s.pool.Query(ctx, `SELECT ct.public_id::text,ct.sequence_no,ct.speaker,ct.final_text,ss.source_start_offset,ss.source_end_offset FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id WHERE sd.public_id=$1::uuid ORDER BY ct.sequence_no`, story.ID)
if err != nil {
return err
}
story.Sources = []StorySource{}
for rows.Next() {
var x StorySource
if err := rows.Scan(&x.TurnID, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil {
rows.Close()
return err
}
story.Sources = append(story.Sources, x)
}
rows.Close()
rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid ORDER BY fc.created_at,fc.public_id`, story.ID)
if err != nil {
return err
}
defer rows.Close()
story.Facts = []FactCandidate{}
for rows.Next() {
var x FactCandidate
var source []byte
if err := rows.Scan(&x.ID, &x.FactType, &x.Content, &x.StatementBasis, &x.Confidence, &x.Sensitivity, &source, &x.Status, &x.Version); err != nil {
return err
}
if err := json.Unmarshal(source, &x.SourceTurnIDs); err != nil {
return err
}
story.Facts = append(story.Facts, x)
}
return rows.Err()
}
const memorySelectSQL = `SELECT cm.public_id::text,sp.public_id::text,mv.content,mv.source_refs,cm.status,cm.sensitivity,mv.version_no,cm.version,ua.public_id::text,mv.confirmed_at,COALESCE(mv.change_reason,''),COALESCE(mi.status,'PENDING'),COALESCE(mi.last_error,''),cm.created_at,cm.updated_at FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN memory_version mv ON mv.id=cm.current_version_id JOIN user_account ua ON ua.id=mv.confirmed_by LEFT JOIN memory_index_entry mi ON mi.memory_version_id=mv.id AND mi.index_type='pgvector'`
func scanMemory(row rowScanner) (CuratedMemory, error) {
var m CuratedMemory
var sourceJSON []byte
if err := row.Scan(&m.ID, &m.SubjectID, &m.Content, &sourceJSON, &m.Status, &m.Sensitivity, &m.VersionNo, &m.Version, &m.ConfirmedBy, &m.ConfirmedAt, &m.ChangeReason, &m.IndexStatus, &m.LastIndexError, &m.CreatedAt, &m.UpdatedAt); err != nil {
return CuratedMemory{}, err
}
if err := json.Unmarshal(sourceJSON, &m.SourceRefs); err != nil {
return CuratedMemory{}, err
}
return m, nil
}
func nullUUID(value string) any {
if value == "" {
return nil
}
return value
}
+273
View File
@@ -0,0 +1,273 @@
package saas
import (
"context"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
)
type ConversationFilter struct {
Status string
EngineMode string
From *time.Time
To *time.Time
Limit int
Cursor string
}
type ConversationSummary struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
DeviceID string `json:"device_id"`
Status string `json:"status"`
EngineMode string `json:"engine_mode"`
RetentionMode string `json:"retention_mode"`
AudioRetentionEnabled bool `json:"audio_retention_enabled"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
DurationMS *int64 `json:"duration_ms,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
FailureCode *string `json:"failure_code,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ConversationPage struct {
Items []ConversationSummary `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ConversationStateEntry struct {
FromStatus *string `json:"from_status,omitempty"`
ToStatus string `json:"to_status"`
Reason *string `json:"reason,omitempty"`
Source string `json:"source"`
OccurredAt time.Time `json:"occurred_at"`
}
type ConversationSnapshotSummary struct {
ConsentSnapshotID string `json:"consent_snapshot_id"`
ConsentSnapshotVersion int64 `json:"consent_snapshot_version"`
ContextSnapshotID string `json:"context_snapshot_id"`
ProfileVersion int64 `json:"profile_version"`
PromptBundleVersion string `json:"prompt_bundle_version"`
ContextPreparationStatus string `json:"context_preparation_status"`
ContextPreparationFailureCode *string `json:"context_preparation_failure_code,omitempty"`
}
type RTCTaskSummary struct {
Provider string `json:"provider"`
Status string `json:"status"`
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
StartAttempts int `json:"start_attempts"`
StopAttempts int `json:"stop_attempts"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
}
type ConversationDetail struct {
ConversationSummary
HouseholdID string `json:"household_id"`
Snapshots ConversationSnapshotSummary `json:"snapshots"`
RTC *RTCTaskSummary `json:"rtc,omitempty"`
History []ConversationStateEntry `json:"history"`
}
type ConversationUsageSummary struct {
ConversationID string `json:"conversation_id"`
CostMicros int64 `json:"cost_micros"`
Currency string `json:"currency"`
Lines []billing.CostSummaryLine `json:"lines"`
}
func (s *Service) ListSubjectConversations(ctx context.Context, principal identity.Principal, subjectPublicID string, filter ConversationFilter) (ConversationPage, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return ConversationPage{}, err
}
if s.cursor == nil {
return ConversationPage{}, errors.New("pagination cursor codec is unavailable")
}
var cursorTime *time.Time
var cursorID *string
if filter.Cursor != "" {
decoded, decodeErr := s.cursor.Decode(filter.Cursor)
if decodeErr != nil {
return ConversationPage{}, pagination.ErrInvalidCursor
}
parsed, parseErr := time.Parse(time.RFC3339Nano, decoded.SortKey)
if parseErr != nil {
return ConversationPage{}, pagination.ErrInvalidCursor
}
cursorTime = &parsed
cursorID = &decoded.ID
}
rows, err := s.pool.Query(ctx, `
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.engine_mode,c.retention_mode,
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
c.stop_reason,c.failure_code,c.created_at,c.updated_at
FROM conversation c JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
WHERE c.tenant_id=$2::uuid AND c.subject_id=$3::uuid
AND (NULLIF($4,'') IS NULL OR c.status=NULLIF($4,''))
AND (NULLIF($5,'') IS NULL OR c.engine_mode=NULLIF($5,''))
AND ($6::timestamptz IS NULL OR c.created_at >= $6)
AND ($7::timestamptz IS NULL OR c.created_at < $7)
AND ($8::timestamptz IS NULL OR (c.created_at,c.public_id)<($8,$9::uuid))
ORDER BY c.created_at DESC,c.public_id DESC LIMIT $10`, subjectPublicID, access.tenantID, access.subjectID,
filter.Status, filter.EngineMode, filter.From, filter.To, cursorTime, cursorID, filter.Limit+1)
if err != nil {
return ConversationPage{}, fmt.Errorf("list subject conversations: %w", err)
}
defer rows.Close()
items := make([]ConversationSummary, 0, filter.Limit+1)
for rows.Next() {
item, scanErr := scanConversationSummary(rows)
if scanErr != nil {
return ConversationPage{}, scanErr
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return ConversationPage{}, fmt.Errorf("iterate subject conversations: %w", err)
}
page := ConversationPage{Items: items}
if len(items) > filter.Limit {
page.Items = items[:filter.Limit]
last := page.Items[len(page.Items)-1]
page.NextCursor, err = s.cursor.Encode(last.CreatedAt.UTC().Format(time.RFC3339Nano), last.ID)
if err != nil {
return ConversationPage{}, fmt.Errorf("encode next conversation cursor: %w", err)
}
}
return page, nil
}
func (s *Service) GetConversation(ctx context.Context, principal identity.Principal, conversationPublicID string) (ConversationDetail, error) {
var result ConversationDetail
var duration *int64
var rtcProvider, rtcStatus *string
var rtcLeaseExpiresAt, rtcLastCheckedAt *time.Time
var rtcStartAttempts, rtcStopAttempts *int
err := s.pool.QueryRow(ctx, `
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.engine_mode,c.retention_mode,
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
c.stop_reason,c.failure_code,c.created_at,c.updated_at,h.public_id::text,
cs.public_id::text,cs.snapshot_version,xs.public_id::text,xs.profile_version,xs.prompt_bundle_version,
xs.preparation_status,xs.preparation_failure_code,
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at
FROM conversation c
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status <> 'deleted'
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended')
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
JOIN context_snapshot xs ON xs.id=c.context_snapshot_id AND xs.tenant_id=c.tenant_id
LEFT JOIN rtc_task r ON r.conversation_id=c.id
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, conversationPublicID, principal.Issuer, principal.Subject).Scan(
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.EngineMode, &result.RetentionMode,
&result.StartedAt, &result.EndedAt, &duration, &result.StopReason, &result.FailureCode, &result.CreatedAt, &result.UpdatedAt, &result.HouseholdID,
&result.Snapshots.ConsentSnapshotID, &result.Snapshots.ConsentSnapshotVersion, &result.Snapshots.ContextSnapshotID, &result.Snapshots.ProfileVersion, &result.Snapshots.PromptBundleVersion,
&result.Snapshots.ContextPreparationStatus, &result.Snapshots.ContextPreparationFailureCode,
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ConversationDetail{}, ErrAccessDenied
}
if err != nil {
return ConversationDetail{}, fmt.Errorf("get conversation detail: %w", err)
}
result.DurationMS = duration
result.AudioRetentionEnabled = result.RetentionMode == "transcript_and_audio"
if rtcProvider != nil {
result.RTC = &RTCTaskSummary{Provider: *rtcProvider, LeaseExpiresAt: rtcLeaseExpiresAt, LastCheckedAt: rtcLastCheckedAt}
if rtcStatus != nil {
result.RTC.Status = *rtcStatus
}
if rtcStartAttempts != nil {
result.RTC.StartAttempts = *rtcStartAttempts
}
if rtcStopAttempts != nil {
result.RTC.StopAttempts = *rtcStopAttempts
}
}
rows, err := s.pool.Query(ctx, `
SELECT h.from_status,h.to_status,h.reason,h.source,h.occurred_at
FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
WHERE c.public_id=$1::uuid ORDER BY h.occurred_at,h.id`, conversationPublicID)
if err != nil {
return ConversationDetail{}, fmt.Errorf("list conversation history: %w", err)
}
defer rows.Close()
result.History = make([]ConversationStateEntry, 0)
for rows.Next() {
var item ConversationStateEntry
if err := rows.Scan(&item.FromStatus, &item.ToStatus, &item.Reason, &item.Source, &item.OccurredAt); err != nil {
return ConversationDetail{}, fmt.Errorf("scan conversation history: %w", err)
}
result.History = append(result.History, item)
}
if err := rows.Err(); err != nil {
return ConversationDetail{}, fmt.Errorf("iterate conversation history: %w", err)
}
return result, nil
}
func (s *Service) GetConversationUsage(ctx context.Context, principal identity.Principal, conversationPublicID string) (ConversationUsageSummary, error) {
var conversationID string
err := s.pool.QueryRow(ctx, `
SELECT c.id::text
FROM conversation c
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status<>'deleted'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, conversationPublicID, principal.Issuer, principal.Subject).Scan(&conversationID)
if errors.Is(err, pgx.ErrNoRows) {
return ConversationUsageSummary{}, ErrAccessDenied
}
if err != nil {
return ConversationUsageSummary{}, fmt.Errorf("authorize conversation usage: %w", err)
}
rows, err := s.pool.Query(ctx, `
SELECT cue.usage_category,cue.metric_code,SUM(cue.quantity)::text,cue.unit,SUM(ccl.cost_micros)::bigint,ccl.currency
FROM conversation_usage_event cue JOIN conversation_cost_line ccl ON ccl.usage_event_id=cue.id
WHERE cue.conversation_id=$1::uuid
GROUP BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency
ORDER BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency`, conversationID)
if err != nil {
return ConversationUsageSummary{}, fmt.Errorf("list conversation usage: %w", err)
}
defer rows.Close()
result := ConversationUsageSummary{ConversationID: conversationPublicID, Lines: []billing.CostSummaryLine{}}
for rows.Next() {
var item billing.CostSummaryLine
if err = rows.Scan(&item.UsageCategory, &item.MetricCode, &item.Quantity, &item.Unit, &item.CostMicros, &item.Currency); err != nil {
return ConversationUsageSummary{}, fmt.Errorf("scan conversation usage: %w", err)
}
if result.Currency != "" && result.Currency != item.Currency {
return ConversationUsageSummary{}, fmt.Errorf("conversation usage contains multiple currencies")
}
result.Currency = item.Currency
result.CostMicros += item.CostMicros
result.Lines = append(result.Lines, item)
}
if err = rows.Err(); err != nil {
return ConversationUsageSummary{}, fmt.Errorf("iterate conversation usage: %w", err)
}
return result, nil
}
func scanConversationSummary(row rowScanner) (ConversationSummary, error) {
var item ConversationSummary
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.EngineMode, &item.RetentionMode,
&item.StartedAt, &item.EndedAt, &item.DurationMS, &item.StopReason, &item.FailureCode, &item.CreatedAt, &item.UpdatedAt); err != nil {
return ConversationSummary{}, err
}
item.AudioRetentionEnabled = item.RetentionMode == "transcript_and_audio"
return item, nil
}
+107
View File
@@ -0,0 +1,107 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type DeviceClaimResult struct {
BindingID string `json:"binding_id"`
DeviceID string `json:"device_id"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
BoundAt time.Time `json:"bound_at"`
}
func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal, claimCode, householdPublicID, subjectPublicID, requestID string) (DeviceClaimResult, bool, error) {
var result DeviceClaimResult
var replayed bool
digestBytes := sha256.Sum256([]byte(claimCode))
digest := hex.EncodeToString(digestBytes[:])
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityDeviceManage, true)
if err != nil {
return err
}
var subjectID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM subject_profile
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status <> 'deleted'
FOR SHARE`, subjectPublicID, access.tenantID, access.householdID).Scan(&subjectID); errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
} else if err != nil {
return fmt.Errorf("resolve claim subject: %w", err)
}
var claimID, deviceID, devicePublicID string
var expiresAt time.Time
var revokedAt, usedAt *time.Time
var usedBindingID *string
if err := tx.QueryRow(ctx, `
SELECT dc.id::text,dc.device_id::text,d.public_id::text,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id
WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered'
FOR UPDATE OF dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceClaimInvalid
} else if err != nil {
return fmt.Errorf("resolve device claim: %w", err)
}
if revokedAt != nil || !expiresAt.After(time.Now()) {
return ErrDeviceClaimInvalid
}
if usedAt != nil {
if usedBindingID == nil {
return ErrDeviceClaimInvalid
}
if err := tx.QueryRow(ctx, `
SELECT b.public_id::text,d.public_id::text,h.public_id::text,sp.public_id::text,b.bound_at
FROM device_binding b
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
WHERE b.id=$1::uuid`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
return fmt.Errorf("read used device claim binding: %w", err)
}
if result.HouseholdID != householdPublicID || result.SubjectID != subjectPublicID {
return ErrDeviceClaimUsed
}
replayed = true
return nil
}
var activeBinding bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, deviceID).Scan(&activeBinding); err != nil {
return fmt.Errorf("check active device binding: %w", err)
}
if activeBinding {
return ErrDeviceAlreadyBound
}
var bindingInternalID string
if err := tx.QueryRow(ctx, `
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'one_time_claim')
RETURNING id::text,public_id::text,$6::text,$7::text,$8::text,bound_at`,
access.tenantID, deviceID, access.householdID, subjectID, access.userID,
devicePublicID, householdPublicID, subjectPublicID).Scan(&bindingInternalID, &result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
return fmt.Errorf("insert claimed device binding: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid`, bindingInternalID, claimID); err != nil {
return fmt.Errorf("mark device claim used: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "device.claim", ResourceType: "device_binding", ResourceID: strPtr(result.BindingID),
RequestID: requestID, Result: "success", Detail: map[string]any{"device_id": devicePublicID, "household_id": householdPublicID, "subject_id": subjectPublicID},
})
})
return result, replayed, err
}
+130
View File
@@ -0,0 +1,130 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type DeviceCommand struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Type string `json:"type"`
Payload map[string]any `json:"payload"`
Status string `json:"status"`
ResultSummary map[string]any `json:"result_summary"`
ExpiresAt time.Time `json:"expires_at"`
DeliveryAttempts int `json:"delivery_attempts"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Service) CreateDeviceCommand(ctx context.Context, principal identity.Principal, devicePublicID, idempotencyKey, requestID, commandType string, payload map[string]any, expiresInSec int) (DeviceCommand, bool, error) {
requestJSON, err := json.Marshal(map[string]any{"type": commandType, "payload": payload, "expires_in_sec": expiresInSec})
if err != nil {
return DeviceCommand{}, false, err
}
digestValue := sha256.Sum256(requestJSON)
digest := hex.EncodeToString(digestValue[:])
var publicID string
created := false
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
expiresAt := time.Now().UTC().Add(time.Duration(expiresInSec) * time.Second)
access, _, err := resolveDevice(ctx, tx, principal, devicePublicID, identity.CapabilityDeviceManage, true)
if err != nil {
return err
}
var internalID string
err = tx.QueryRow(ctx, `INSERT INTO device_command(tenant_id,device_id,command_type,payload,requested_by,idempotency_key,request_digest,expires_at) VALUES($1::uuid,$2::uuid,$3,$4::jsonb,$5::uuid,$6,$7,$8) ON CONFLICT (tenant_id,requested_by,idempotency_key) DO NOTHING RETURNING id::text,public_id::text`, access.tenantID, access.deviceID, commandType, requestJSONForPayload(payload), access.userID, idempotencyKey, digest, expiresAt).Scan(&internalID, &publicID)
if errors.Is(err, pgx.ErrNoRows) {
var stored string
if err := tx.QueryRow(ctx, `SELECT public_id::text,request_digest FROM device_command WHERE tenant_id=$1::uuid AND requested_by=$2::uuid AND idempotency_key=$3`, access.tenantID, access.userID, idempotencyKey).Scan(&publicID, &stored); err != nil {
return err
}
if stored != digest {
return ErrIdempotencyConflict
}
return nil
}
if err != nil {
return fmt.Errorf("create device command: %w", err)
}
created = true
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "device.command.create", ResourceType: "device_command", ResourceID: strPtr(publicID), RequestID: requestID, Result: "success", Detail: map[string]any{"device_id": devicePublicID, "type": commandType, "expires_at": expiresAt}})
})
if err != nil {
return DeviceCommand{}, false, err
}
item, err := s.GetDeviceCommand(ctx, principal, publicID)
return item, created, err
}
func requestJSONForPayload(payload map[string]any) []byte {
value, _ := json.Marshal(payload)
return value
}
func (s *Service) ListDeviceCommands(ctx context.Context, principal identity.Principal, devicePublicID string) ([]DeviceCommand, error) {
access, _, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityDeviceManage, false)
if err != nil {
return nil, err
}
_, _ = s.pool.Exec(ctx, `UPDATE device_command SET status='EXPIRED',completed_at=now(),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at<=now()`, access.tenantID, access.deviceID)
rows, err := s.pool.Query(ctx, deviceCommandSelectSQL+` WHERE dc.tenant_id=$1::uuid AND dc.device_id=$2::uuid ORDER BY dc.created_at DESC,dc.id DESC LIMIT 100`, access.tenantID, access.deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DeviceCommand{}
for rows.Next() {
item, err := scanDeviceCommand(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetDeviceCommand(ctx context.Context, principal identity.Principal, publicID string) (DeviceCommand, error) {
item, err := scanDeviceCommand(s.pool.QueryRow(ctx, deviceCommandSelectSQL+` JOIN device_binding b ON b.device_id=d.id AND b.tenant_id=d.tenant_id AND b.unbound_at IS NULL JOIN household_member hm ON hm.household_id=b.household_id AND hm.tenant_id=b.tenant_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=b.tenant_id AND ua.status='active' WHERE dc.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject))
if errors.Is(err, pgx.ErrNoRows) {
return DeviceCommand{}, ErrAccessDenied
}
if err != nil {
return DeviceCommand{}, err
}
var role string
if err := s.pool.QueryRow(ctx, `SELECT hm.role FROM device_command dc JOIN device_binding b ON b.device_id=dc.device_id AND b.tenant_id=dc.tenant_id AND b.unbound_at IS NULL JOIN household_member hm ON hm.household_id=b.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=dc.tenant_id WHERE dc.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject).Scan(&role); err != nil || !identity.Can(identity.Role(role), identity.CapabilityDeviceManage) {
return DeviceCommand{}, ErrAccessDenied
}
return item, nil
}
const deviceCommandSelectSQL = `SELECT dc.public_id::text,d.public_id::text,dc.command_type,dc.payload,dc.status,dc.result_summary,dc.expires_at,dc.delivery_attempts,dc.version,dc.created_at,dc.updated_at FROM device_command dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id`
func scanDeviceCommand(row rowScanner) (DeviceCommand, error) {
var item DeviceCommand
var payload, result []byte
if err := row.Scan(&item.ID, &item.DeviceID, &item.Type, &payload, &item.Status, &result, &item.ExpiresAt, &item.DeliveryAttempts, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return DeviceCommand{}, err
}
if err := decodeObject(payload, &item.Payload); err != nil {
return DeviceCommand{}, err
}
if err := decodeObject(result, &item.ResultSummary); err != nil {
return DeviceCommand{}, err
}
return item, nil
}
@@ -0,0 +1,92 @@
package saas
import (
"context"
"errors"
"os"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestDeviceCommandCreateReplayConflictListAndExpiry(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 := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
outsider := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
deviceID := "10000000-0000-0000-0000-000000000511"
key := "saas-device-command-" + uuid.NewString()
requestPrefix := "saas-device-command-"
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%")
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE idempotency_key=$1`, key)
}()
payload := map[string]any{"config_revision": float64(3), "reason": "core coverage"}
createdCommand, created, err := service.CreateDeviceCommand(ctx, owner, deviceID, key, requestPrefix+"create", "apply_config", payload, 120)
if err != nil || !created || createdCommand.ID == "" || createdCommand.DeviceID != deviceID || createdCommand.Type != "apply_config" || createdCommand.Status != "QUEUED" || createdCommand.Version != 1 {
t.Fatalf("created command=%+v created=%t err=%v", createdCommand, created, err)
}
replayed, createdAgain, err := service.CreateDeviceCommand(ctx, owner, deviceID, key, requestPrefix+"replay", "apply_config", payload, 120)
if err != nil || createdAgain || replayed.ID != createdCommand.ID {
t.Fatalf("replayed command=%+v created=%t err=%v", replayed, createdAgain, err)
}
if _, _, err := service.CreateDeviceCommand(ctx, owner, deviceID, key, requestPrefix+"conflict", "apply_config", map[string]any{"config_revision": float64(4)}, 120); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("command body conflict error=%v", err)
}
loaded, err := service.GetDeviceCommand(ctx, owner, createdCommand.ID)
if err != nil || loaded.ID != createdCommand.ID || loaded.Payload["reason"] != "core coverage" || loaded.ResultSummary == nil {
t.Fatalf("loaded command=%+v err=%v", loaded, err)
}
items, err := service.ListDeviceCommands(ctx, owner, deviceID)
if err != nil {
t.Fatal(err)
}
found := false
for _, item := range items {
if item.ID == createdCommand.ID {
found = true
}
}
if !found {
t.Fatalf("command %s missing from list", createdCommand.ID)
}
if _, err := service.GetDeviceCommand(ctx, outsider, createdCommand.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider command access error=%v", err)
}
if _, err := service.ListDeviceCommands(ctx, outsider, deviceID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider command list error=%v", err)
}
if _, err := service.GetDeviceCommand(ctx, owner, uuid.NewString()); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("missing command error=%v", err)
}
if _, err := pool.Exec(ctx, `UPDATE device_command SET expires_at=now()-interval '1 second' WHERE public_id=$1::uuid`, createdCommand.ID); err != nil {
t.Fatal(err)
}
items, err = service.ListDeviceCommands(ctx, owner, deviceID)
if err != nil {
t.Fatal(err)
}
found = false
for _, item := range items {
if item.ID == createdCommand.ID && item.Status == "EXPIRED" && item.Version == 2 {
found = true
}
}
if !found {
t.Fatalf("expired command not returned: %+v", items)
}
}
+262
View File
@@ -0,0 +1,262 @@
package saas
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type ManagedDevice struct {
ID string `json:"id"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
SubjectName string `json:"subject_name"`
Model string `json:"model"`
HardwareRevision *string `json:"hardware_revision,omitempty"`
Status string `json:"status"`
MinimumSecurityVersion *string `json:"minimum_security_version,omitempty"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"`
FirmwareVersion *string `json:"firmware_version,omitempty"`
DesiredRevision int64 `json:"desired_revision"`
ReportedRevision *int64 `json:"reported_revision,omitempty"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
BoundAt time.Time `json:"bound_at"`
RegisteredAt time.Time `json:"registered_at"`
Version int64 `json:"version"`
}
type DeviceTwin struct {
DeviceID string `json:"device_id"`
DesiredRevision int64 `json:"desired_revision"`
DesiredConfig map[string]any `json:"desired_config"`
ReportedRevision *int64 `json:"reported_revision,omitempty"`
ReportedConfig map[string]any `json:"reported_config"`
FirmwareVersion *string `json:"firmware_version,omitempty"`
BoardRevision *string `json:"board_revision,omitempty"`
RTCSDKVersion *string `json:"rtc_sdk_version,omitempty"`
Capabilities map[string]any `json:"capabilities"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
LastErrorCode *string `json:"last_error_code,omitempty"`
LastErrorSeverity *string `json:"last_error_severity,omitempty"`
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
SyncState string `json:"sync_state"`
UpdatedAt time.Time `json:"updated_at"`
}
type DevicePresence struct {
DeviceID string `json:"device_id"`
Online bool `json:"online"`
Status string `json:"status"`
ConversationID *string `json:"conversation_id,omitempty"`
SequenceNo *int64 `json:"sequence_no,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
Firmware *string `json:"firmware_version,omitempty"`
Source string `json:"source"`
}
func (s *Service) ListDevices(ctx context.Context, principal identity.Principal) ([]ManagedDevice, error) {
rows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,
d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,dt.firmware_version,
dt.desired_revision,dt.reported_revision,dt.last_reported_at,b.bound_at,d.registered_at,d.version,hm.role
FROM user_account ua
JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active'
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status <> 'deleted'
JOIN device_binding b ON b.household_id=h.id AND b.tenant_id=h.tenant_id AND b.unbound_at IS NULL
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id AND sp.status IN ('active','suspended')
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active'
ORDER BY dt.last_reported_at DESC NULLS LAST,d.public_id`, principal.Issuer, principal.Subject)
if err != nil {
return nil, fmt.Errorf("list managed devices: %w", err)
}
defer rows.Close()
result := make([]ManagedDevice, 0)
for rows.Next() {
var item ManagedDevice
var role identity.Role
if err := rows.Scan(&item.ID, &item.HouseholdID, &item.SubjectID, &item.SubjectName,
&item.Model, &item.HardwareRevision, &item.Status, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion, &item.FirmwareVersion,
&item.DesiredRevision, &item.ReportedRevision, &item.LastReportedAt, &item.BoundAt, &item.RegisteredAt, &item.Version, &role); err != nil {
return nil, fmt.Errorf("scan managed device: %w", err)
}
if identity.Can(role, identity.CapabilityHouseholdRead) {
result = append(result, item)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate managed devices: %w", err)
}
return result, nil
}
func (s *Service) GetDevice(ctx context.Context, principal identity.Principal, devicePublicID string) (ManagedDevice, error) {
access, device, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityHouseholdRead, false)
_ = access
return device, err
}
func (s *Service) GetDeviceTwin(ctx context.Context, principal identity.Principal, devicePublicID string) (DeviceTwin, error) {
access, _, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityHouseholdRead, false)
if err != nil {
return DeviceTwin{}, err
}
return readDeviceTwin(ctx, s.pool, access)
}
func (s *Service) PatchDeviceTwin(ctx context.Context, principal identity.Principal, devicePublicID, requestID string, expectedRevision int64, desired map[string]any) (DeviceTwin, error) {
var result DeviceTwin
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveDevice(ctx, tx, principal, devicePublicID, identity.CapabilityDeviceManage, true)
if err != nil {
return err
}
if err := concurrency.RequireVersion(expectedRevision, access.desiredRevision); err != nil {
return ErrVersionConflict
}
desiredJSON, _ := json.Marshal(desired)
if _, err := tx.Exec(ctx, `UPDATE device_twin SET desired_config=desired_config || $1::jsonb,desired_revision=desired_revision+1,updated_at=now() WHERE device_id=$2::uuid AND tenant_id=$3::uuid`, desiredJSON, access.deviceID, access.tenantID); err != nil {
return fmt.Errorf("update desired device configuration: %w", err)
}
result, err = readDeviceTwin(ctx, tx, access)
if err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "device.twin.update", ResourceType: "device", ResourceID: strPtr(devicePublicID),
RequestID: requestID, Result: "success", Detail: map[string]any{"from_revision": expectedRevision, "to_revision": result.DesiredRevision},
})
})
return result, err
}
func (s *Service) GetDevicePresence(ctx context.Context, principal identity.Principal, devicePublicID string) (DevicePresence, error) {
access, device, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityHouseholdRead, false)
if err != nil {
return DevicePresence{}, err
}
result := DevicePresence{DeviceID: devicePublicID, Online: false, Status: "offline", LastSeenAt: device.LastReportedAt, Firmware: device.FirmwareVersion, Source: "database"}
if s.redis == nil {
return result, nil
}
fields, err := s.redis.HGetAll(ctx, "voice-platform:device:presence:"+devicePublicID).Result()
if err != nil || len(fields) == 0 {
return result, nil
}
result.Source = "redis"
result.Online = fields["online"] == "true"
if value := fields["status"]; value != "" {
result.Status = value
} else if result.Online {
result.Status = "online"
}
if value := fields["conversation_id"]; value != "" {
result.ConversationID = &value
}
if value := fields["sequence_no"]; value != "" {
if sequence, parseErr := strconv.ParseInt(value, 10, 64); parseErr == nil {
result.SequenceNo = &sequence
}
}
if value := fields["last_seen_at"]; value != "" {
if observed, parseErr := time.Parse(time.RFC3339Nano, value); parseErr == nil {
result.LastSeenAt = &observed
}
}
_ = access
return result, nil
}
type deviceAccess struct {
tenantID, tenantPublicID, userID, userPublicID string
householdID, householdPublicID, deviceID string
role identity.Role
desiredRevision int64
}
func resolveDevice(ctx context.Context, q queryRow, principal identity.Principal, publicID string, capability identity.Capability, lock bool) (deviceAccess, ManagedDevice, error) {
sql := `
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,h.id::text,h.public_id::text,d.id::text,hm.role,
d.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,d.model,d.hardware_revision,
d.status,d.minimum_security_version,d.minimum_firmware_version,dt.firmware_version,dt.desired_revision,dt.reported_revision,
dt.last_reported_at,b.bound_at,d.registered_at,d.version
FROM device d
JOIN tenant t ON t.id=d.tenant_id AND t.status='active'
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
JOIN device_binding b ON b.device_id=d.id AND b.tenant_id=d.tenant_id AND b.unbound_at IS NULL
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id AND h.status <> 'deleted'
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id AND sp.status IN ('active','suspended')
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE d.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF dt`
}
var access deviceAccess
var device ManagedDevice
var role string
err := q.QueryRow(ctx, sql, publicID, principal.Issuer, principal.Subject).Scan(
&access.tenantID, &access.tenantPublicID, &access.userID, &access.userPublicID, &access.householdID, &access.householdPublicID, &access.deviceID, &role,
&device.ID, &device.HouseholdID, &device.SubjectID, &device.SubjectName, &device.Model, &device.HardwareRevision,
&device.Status, &device.MinimumSecurityVersion, &device.MinimumFirmwareVersion, &device.FirmwareVersion, &device.DesiredRevision, &device.ReportedRevision,
&device.LastReportedAt, &device.BoundAt, &device.RegisteredAt, &device.Version)
if errors.Is(err, pgx.ErrNoRows) {
return deviceAccess{}, ManagedDevice{}, ErrAccessDenied
}
if err != nil {
return deviceAccess{}, ManagedDevice{}, fmt.Errorf("resolve device access: %w", err)
}
access.role = identity.Role(role)
access.desiredRevision = device.DesiredRevision
if !identity.Can(access.role, capability) {
return deviceAccess{}, ManagedDevice{}, ErrAccessDenied
}
return access, device, nil
}
func readDeviceTwin(ctx context.Context, q queryRow, access deviceAccess) (DeviceTwin, error) {
var result DeviceTwin
var desiredJSON, reportedJSON, capabilitiesJSON []byte
err := q.QueryRow(ctx, `
SELECT d.public_id::text,dt.desired_revision,dt.desired_config,dt.reported_revision,dt.reported_config,
dt.firmware_version,dt.board_revision,dt.rtc_sdk_version,dt.capabilities,dt.last_reported_at,
dt.last_error_code,dt.last_error_severity,dt.last_error_at,dt.updated_at
FROM device_twin dt JOIN device d ON d.id=dt.device_id AND d.tenant_id=dt.tenant_id
WHERE dt.device_id=$1::uuid AND dt.tenant_id=$2::uuid`, access.deviceID, access.tenantID).Scan(
&result.DeviceID, &result.DesiredRevision, &desiredJSON, &result.ReportedRevision, &reportedJSON,
&result.FirmwareVersion, &result.BoardRevision, &result.RTCSDKVersion, &capabilitiesJSON, &result.LastReportedAt,
&result.LastErrorCode, &result.LastErrorSeverity, &result.LastErrorAt, &result.UpdatedAt)
if err != nil {
return DeviceTwin{}, fmt.Errorf("read device twin: %w", err)
}
if err := decodeObject(desiredJSON, &result.DesiredConfig); err != nil {
return DeviceTwin{}, err
}
if err := decodeObject(reportedJSON, &result.ReportedConfig); err != nil {
return DeviceTwin{}, err
}
if err := decodeObject(capabilitiesJSON, &result.Capabilities); err != nil {
return DeviceTwin{}, err
}
switch {
case result.ReportedRevision == nil:
result.SyncState = "never_reported"
case *result.ReportedRevision >= result.DesiredRevision:
result.SyncState = "applied"
default:
result.SyncState = "pending"
}
return result, nil
}
+185
View File
@@ -0,0 +1,185 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
)
var ErrExportUnavailable = errors.New("export download is not available")
type exportURLSigner interface {
PresignDownload(context.Context, string, time.Duration, string) (string, error)
}
type ExportTask struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Scope map[string]any `json:"scope"`
Status string `json:"status"`
ObjectSize *int64 `json:"object_size,omitempty"`
ContentDigest string `json:"content_digest,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Attempts int `json:"attempts"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
DownloadUntil *time.Time `json:"download_until,omitempty"`
objectRef string
}
func (s *Service) ConfigureExportSigning(signer exportURLSigner, downloadTTL time.Duration) {
s.exportSigner = signer
s.exportDownloadTTL = downloadTTL
}
func (s *Service) CreateExportTask(ctx context.Context, principal identity.Principal, subjectPublicID, idempotencyKey, requestID string, scope map[string]any) (ExportTask, bool, error) {
if scope == nil {
scope = map[string]any{}
}
requestBytes, err := json.Marshal(scope)
if err != nil {
return ExportTask{}, false, err
}
digestValue := sha256.Sum256(requestBytes)
digest := hex.EncodeToString(digestValue[:])
created := false
var publicID string
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilityPrivacyManage, true)
if err != nil {
return err
}
scopeJSON, _ := json.Marshal(scope)
var internalID string
err = tx.QueryRow(ctx, `INSERT INTO export_task(tenant_id,subject_id,requested_by,scope,idempotency_key,request_digest) VALUES($1::uuid,$2::uuid,$3::uuid,$4::jsonb,$5,$6) ON CONFLICT (tenant_id,requested_by,idempotency_key) DO NOTHING RETURNING id::text,public_id::text`, access.tenantID, access.subjectID, access.userID, scopeJSON, idempotencyKey, digest).Scan(&internalID, &publicID)
if errors.Is(err, pgx.ErrNoRows) {
var storedDigest string
if err := tx.QueryRow(ctx, `SELECT public_id::text,request_digest FROM export_task WHERE tenant_id=$1::uuid AND requested_by=$2::uuid AND idempotency_key=$3 FOR UPDATE`, access.tenantID, access.userID, idempotencyKey).Scan(&publicID, &storedDigest); err != nil {
return err
}
if storedDigest != digest {
return ErrIdempotencyConflict
}
return nil
}
if err != nil {
return fmt.Errorf("create export task: %w", err)
}
created = true
dedupe := "export-generate:" + internalID
payload, _ := json.Marshal(map[string]any{"export_task_id": internalID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "export_task", AggregateID: internalID, EventType: "export.generate_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "export.request", ResourceType: "export_task", ResourceID: strPtr(publicID), RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "scope": scope}})
})
if err != nil {
return ExportTask{}, false, err
}
result, err := s.GetExportTask(ctx, principal, publicID)
return result, created, err
}
func (s *Service) ListExportTasks(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]ExportTask, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilityPrivacyRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, exportTaskSelectSQL+` WHERE et.tenant_id=$1::uuid AND et.subject_id=$2::uuid ORDER BY et.created_at DESC,et.id DESC`, access.tenantID, access.subjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]ExportTask, 0)
for rows.Next() {
item, err := scanExportTask(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetExportTask(ctx context.Context, principal identity.Principal, publicID string) (ExportTask, error) {
row := s.pool.QueryRow(ctx, exportTaskSelectSQL+` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id AND h.status<>'deleted' JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active' WHERE et.public_id=$1::uuid AND sp.status IN ('active','suspended') AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject)
item, err := scanExportTask(row)
if errors.Is(err, pgx.ErrNoRows) {
return ExportTask{}, ErrAccessDenied
}
if err != nil {
return ExportTask{}, err
}
var role string
if err := s.pool.QueryRow(ctx, `SELECT hm.role FROM export_task et JOIN subject_profile sp ON sp.id=et.subject_id AND sp.status IN ('active','suspended') JOIN household_member hm ON hm.household_id=sp.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=et.tenant_id AND ua.status='active' WHERE et.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject).Scan(&role); err != nil || !identity.Can(identity.Role(role), identity.CapabilityPrivacyRead) {
return ExportTask{}, ErrAccessDenied
}
if item.Status == "READY" && item.ExpiresAt != nil {
if s.exportSigner == nil || s.exportDownloadTTL <= 0 {
return ExportTask{}, ErrExportUnavailable
}
ttl := min(s.exportDownloadTTL, time.Until(*item.ExpiresAt))
if ttl <= 0 {
item.Status = "EXPIRED"
return item, nil
}
url, err := s.exportSigner.PresignDownload(ctx, item.objectRef, ttl, "subject-data-"+item.ID+".json")
if err != nil {
return ExportTask{}, fmt.Errorf("presign export download: %w", err)
}
until := time.Now().Add(ttl)
item.DownloadURL = url
item.DownloadUntil = &until
}
return item, nil
}
const exportTaskSelectSQL = `SELECT et.public_id::text,sp.public_id::text,et.scope,et.status,et.object_ref,et.object_size,COALESCE(et.content_digest,''),et.expires_at,et.attempts,COALESCE(et.last_error,''),et.created_at,et.updated_at,et.completed_at FROM export_task et JOIN subject_profile sp ON sp.id=et.subject_id AND sp.tenant_id=et.tenant_id`
func scanExportTask(row rowScanner) (ExportTask, error) {
var item ExportTask
var scopeJSON []byte
var objectRef *string
if err := row.Scan(&item.ID, &item.SubjectID, &scopeJSON, &item.Status, &objectRef, &item.ObjectSize, &item.ContentDigest, &item.ExpiresAt, &item.Attempts, &item.LastError, &item.CreatedAt, &item.UpdatedAt, &item.CompletedAt); err != nil {
return ExportTask{}, err
}
if objectRef != nil {
item.objectRef = *objectRef
}
if err := decodeObject(scopeJSON, &item.Scope); err != nil {
return ExportTask{}, err
}
return item, nil
}
func NormalizeExportCategories(values []string) ([]string, error) {
allowed := map[string]bool{"profile": true, "consents": true, "device_bindings": true, "conversations": true, "transcripts": true, "summaries": true, "stories": true, "memories": true}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, raw := range values {
value := strings.TrimSpace(raw)
if !allowed[value] {
return nil, fmt.Errorf("unsupported export category %q", value)
}
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result, nil
}
+19
View File
@@ -0,0 +1,19 @@
package saas
import (
"reflect"
"testing"
)
func TestNormalizeExportCategories(t *testing.T) {
actual, err := NormalizeExportCategories([]string{" profile ", "memories", "profile"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, []string{"profile", "memories"}) {
t.Fatalf("categories = %#v", actual)
}
if _, err := NormalizeExportCategories([]string{"provider_credentials"}); err == nil {
t.Fatal("unsupported export category should fail")
}
}
+185
View File
@@ -0,0 +1,185 @@
package saas
import (
"context"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type SubjectFact struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
FactType string `json:"fact_type"`
Content string `json:"content"`
SourceType string `json:"source_type"`
SourceRef string `json:"source_ref,omitempty"`
ConfirmationStatus string `json:"confirmation_status"`
Sensitivity string `json:"sensitivity"`
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidTo *time.Time `json:"valid_to,omitempty"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateFactInput struct {
FactType string `json:"fact_type"`
Content string `json:"content"`
SourceType string `json:"source_type"`
SourceRef string `json:"source_ref,omitempty"`
ConfirmationStatus string `json:"confirmation_status"`
Sensitivity string `json:"sensitivity"`
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidTo *time.Time `json:"valid_to,omitempty"`
}
type PatchFactInput struct {
FactType *string `json:"fact_type,omitempty"`
Content *string `json:"content,omitempty"`
SourceRef *string `json:"source_ref,omitempty"`
ConfirmationStatus *string `json:"confirmation_status,omitempty"`
Sensitivity *string `json:"sensitivity,omitempty"`
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidTo *time.Time `json:"valid_to,omitempty"`
}
func (s *Service) ListSubjectFacts(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]SubjectFact, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT f.public_id::text,$1::text,f.fact_type,f.content,f.source_type,COALESCE(f.source_ref,''),
f.confirmation_status,f.sensitivity,f.valid_from,f.valid_to,f.version,f.created_at,f.updated_at
FROM subject_fact f WHERE f.tenant_id=$2::uuid AND f.subject_id=$3::uuid
ORDER BY f.updated_at DESC,f.public_id`, subjectPublicID, access.tenantID, access.subjectID)
if err != nil {
return nil, fmt.Errorf("list subject facts: %w", err)
}
defer rows.Close()
result := make([]SubjectFact, 0)
for rows.Next() {
item, scanErr := scanFact(rows)
if scanErr != nil {
return nil, scanErr
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate subject facts: %w", err)
}
return result, nil
}
func (s *Service) CreateSubjectFact(ctx context.Context, principal identity.Principal, subjectPublicID, requestID string, input CreateFactInput) (SubjectFact, error) {
var result SubjectFact
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
row := tx.QueryRow(ctx, `
INSERT INTO subject_fact(tenant_id,subject_id,fact_type,content,source_type,source_ref,
confirmation_status,sensitivity,valid_from,valid_to,created_by,updated_by)
VALUES($1::uuid,$2::uuid,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11::uuid,$11::uuid)
RETURNING public_id::text,$12::text,fact_type,content,source_type,COALESCE(source_ref,''),
confirmation_status,sensitivity,valid_from,valid_to,version,created_at,updated_at`,
access.tenantID, access.subjectID, input.FactType, input.Content, input.SourceType, input.SourceRef,
input.ConfirmationStatus, input.Sensitivity, input.ValidFrom, input.ValidTo, access.userID, subjectPublicID)
result, err = scanFact(row)
if err != nil {
return fmt.Errorf("insert subject fact: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject_fact.create", ResourceType: "subject_fact", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "source_type": result.SourceType, "status": result.ConfirmationStatus, "version": result.Version},
})
})
return result, err
}
func (s *Service) PatchSubjectFact(ctx context.Context, principal identity.Principal, subjectPublicID, factPublicID, requestID string, expectedVersion int64, input PatchFactInput) (SubjectFact, error) {
var result SubjectFact
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
var current SubjectFact
current, err = scanFact(tx.QueryRow(ctx, `
SELECT f.public_id::text,$1::text,f.fact_type,f.content,f.source_type,COALESCE(f.source_ref,''),
f.confirmation_status,f.sensitivity,f.valid_from,f.valid_to,f.version,f.created_at,f.updated_at
FROM subject_fact f WHERE f.public_id=$2::uuid AND f.tenant_id=$3::uuid AND f.subject_id=$4::uuid
FOR UPDATE`, subjectPublicID, factPublicID, access.tenantID, access.subjectID))
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("read subject fact for update: %w", err)
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
factType, content, sourceRef, status, sensitivity := current.FactType, current.Content, current.SourceRef, current.ConfirmationStatus, current.Sensitivity
validFrom, validTo := current.ValidFrom, current.ValidTo
if input.FactType != nil {
factType = *input.FactType
}
if input.Content != nil {
content = *input.Content
}
if input.SourceRef != nil {
sourceRef = *input.SourceRef
}
if input.ConfirmationStatus != nil {
status = *input.ConfirmationStatus
}
if input.Sensitivity != nil {
sensitivity = *input.Sensitivity
}
if input.ValidFrom != nil {
validFrom = input.ValidFrom
}
if input.ValidTo != nil {
validTo = input.ValidTo
}
if status == "confirmed" && sourceRef == "" {
return ErrFactSourceRequired
}
result, err = scanFact(tx.QueryRow(ctx, `
UPDATE subject_fact SET fact_type=$1,content=$2,source_ref=NULLIF($3,''),confirmation_status=$4,
sensitivity=$5,valid_from=$6,valid_to=$7,updated_by=$8::uuid,version=version+1,updated_at=now()
WHERE id=(SELECT id FROM subject_fact WHERE public_id=$9::uuid) AND tenant_id=$10::uuid
RETURNING public_id::text,$11::text,fact_type,content,source_type,COALESCE(source_ref,''),
confirmation_status,sensitivity,valid_from,valid_to,version,created_at,updated_at`,
factType, content, sourceRef, status, sensitivity, validFrom, validTo, access.userID, factPublicID, access.tenantID, subjectPublicID))
if err != nil {
return fmt.Errorf("update subject fact: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject_fact.update", ResourceType: "subject_fact", ResourceID: strPtr(result.ID), RequestID: requestID, Result: "success",
Detail: map[string]any{"subject_id": subjectPublicID, "from_version": expectedVersion, "to_version": result.Version, "status": result.ConfirmationStatus},
})
})
return result, err
}
var ErrFactSourceRequired = errors.New("confirmed fact requires a source reference")
func scanFact(row rowScanner) (SubjectFact, error) {
var item SubjectFact
if err := row.Scan(&item.ID, &item.SubjectID, &item.FactType, &item.Content, &item.SourceType, &item.SourceRef,
&item.ConfirmationStatus, &item.Sensitivity, &item.ValidFrom, &item.ValidTo, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return SubjectFact{}, err
}
return item, nil
}
+948
View File
@@ -0,0 +1,948 @@
package saas
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
var (
ErrFirmwareStateConflict = errors.New("firmware release or rollout state conflict")
ErrFirmwareSigningKeyConflict = errors.New("firmware signing key identity conflict")
ErrFirmwareSigningKeyInUse = errors.New("firmware signing key is still in use")
ErrFirmwareSigningKeyInactive = errors.New("firmware signing key is not active")
ErrFirmwareReleaseInUse = errors.New("firmware release has running rollouts")
)
type firmwareObjectStore interface {
PresignUpload(context.Context, string, time.Duration) (string, error)
PresignDownload(context.Context, string, time.Duration, string) (string, error)
VerifyDigest(context.Context, string, string, int64) error
Remove(context.Context, string) error
}
func (s *Service) ConfigureFirmwareStore(store firmwareObjectStore, ttl time.Duration) {
s.firmwareStore = store
s.firmwareURLTTL = ttl
}
type FirmwareRelease struct {
ID string `json:"id"`
Model string `json:"model"`
HardwareRevision string `json:"hardware_revision,omitempty"`
Version string `json:"version"`
ExpectedSize int64 `json:"expected_size"`
ExpectedDigest string `json:"expected_digest"`
SignatureAlgorithm string `json:"signature_algorithm"`
Signature string `json:"signature"`
SigningKeyID string `json:"signing_key_id"`
MinimumSecurityVersion string `json:"minimum_security_version,omitempty"`
ReleaseNotes string `json:"release_notes,omitempty"`
Status string `json:"status"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UploadURL string `json:"upload_url,omitempty"`
UploadUntil *time.Time `json:"upload_until,omitempty"`
objectRef string
}
type FirmwareRollout struct {
ID string `json:"id"`
ReleaseID string `json:"release_id"`
HouseholdID string `json:"household_id"`
Name string `json:"name"`
Status string `json:"status"`
TargetPercentage int `json:"target_percentage"`
Targeted int `json:"targeted"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Assignments []FirmwareAssignment `json:"assignments,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FirmwareAssignment struct {
DeviceID string `json:"device_id"`
DeviceModel string `json:"device_model"`
PreviousVersion string `json:"previous_version,omitempty"`
Status string `json:"status"`
ProgressPercent *int `json:"progress_percent,omitempty"`
LastResult map[string]any `json:"last_result"`
Events []FirmwareAssignmentEvent `json:"events"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type FirmwareAssignmentEvent struct {
ID string `json:"id"`
CommandID string `json:"command_id"`
CommandStatus string `json:"command_status"`
AssignmentStatus string `json:"assignment_status"`
Phase *string `json:"phase,omitempty"`
ProgressPercent *int `json:"progress_percent,omitempty"`
ResultCode *string `json:"result_code,omitempty"`
ActiveSlot *string `json:"active_slot,omitempty"`
TargetSlot *string `json:"target_slot,omitempty"`
PreviousSlot *string `json:"previous_slot,omitempty"`
FailedSlot *string `json:"failed_slot,omitempty"`
BootAttemptID *string `json:"boot_attempt_id,omitempty"`
BootHealthDeadline *string `json:"boot_health_deadline,omitempty"`
RollbackApplied *bool `json:"rollback_applied,omitempty"`
RollbackVersion *string `json:"rollback_version,omitempty"`
InstalledVersion *string `json:"installed_version,omitempty"`
VerifiedSHA256 *string `json:"verified_sha256,omitempty"`
VerifiedSigningKeyID *string `json:"verified_signing_key_id,omitempty"`
ResultDigest string `json:"result_digest"`
ReportedAt time.Time `json:"reported_at"`
}
type FirmwareSigningKey struct {
KeyID string `json:"key_id"`
FingerprintSHA256 string `json:"fingerprint_sha256"`
Status string `json:"status"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
UploadingReleases int `json:"uploading_releases"`
ReadyReleases int `json:"ready_releases"`
RunningRollouts int `json:"running_rollouts"`
}
type FirmwareReleaseImpact struct {
ReleaseID string `json:"release_id"`
ReleaseStatus string `json:"release_status"`
Rollouts FirmwareImpactRolloutCount `json:"rollouts"`
Devices FirmwareImpactDeviceCount `json:"devices"`
Commands FirmwareImpactCommandCount `json:"commands"`
ObservationDevices []FirmwareImpactDevice `json:"observation_devices"`
ObservationDevicesTruncated bool `json:"observation_devices_truncated"`
CalculatedAt time.Time `json:"calculated_at"`
}
type FirmwareImpactRolloutCount struct {
Total int `json:"total"`
Running int `json:"running"`
Paused int `json:"paused"`
Completed int `json:"completed"`
RolledBack int `json:"rolled_back"`
}
type FirmwareImpactDeviceCount struct {
Total int `json:"total"`
Assigned int `json:"assigned"`
Downloading int `json:"downloading"`
Installing int `json:"installing"`
Confirmed int `json:"confirmed"`
Failed int `json:"failed"`
RolledBack int `json:"rolled_back"`
Observing int `json:"observing"`
}
type FirmwareImpactCommandCount struct {
Total int `json:"total"`
Queued int `json:"queued"`
Delivered int `json:"delivered"`
Acknowledged int `json:"acknowledged"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Expired int `json:"expired"`
CancelledBeforeDelivery int `json:"cancelled_before_delivery"`
}
type FirmwareImpactDevice struct {
DeviceID string `json:"device_id"`
DeviceModel string `json:"device_model"`
RolloutID string `json:"rollout_id"`
RolloutName string `json:"rollout_name"`
RolloutStatus string `json:"rollout_status"`
AssignmentStatus string `json:"assignment_status"`
ProgressPercent *int `json:"progress_percent,omitempty"`
CommandStatus string `json:"command_status"`
ObservationReason string `json:"observation_reason"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type PrepareFirmwareInput struct {
Model, HardwareRevision, Version, ExpectedDigest, Signature, SigningKeyID, MinimumSecurityVersion, ReleaseNotes string
ExpectedSize int64
}
func (s *Service) RegisterFirmwareSigningKey(ctx context.Context, p identity.Principal, householdPublicID, requestID, keyID, publicKeyBase64 string) error {
decoded, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil || len(decoded) != ed25519.PublicKeySize || base64.StdEncoding.EncodeToString(decoded) != publicKeyBase64 || keyID == "" || len([]rune(keyID)) > 120 {
return errors.New("invalid Ed25519 public key")
}
return database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return err
}
var existingPublicKey, status string
err = tx.QueryRow(ctx, `SELECT public_key_base64,status FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 FOR UPDATE`, a.tenantID, keyID).Scan(&existingPublicKey, &status)
if errors.Is(err, pgx.ErrNoRows) {
if _, err := tx.Exec(ctx, `INSERT INTO firmware_signing_key(tenant_id,key_id,public_key_base64,created_by) VALUES($1::uuid,$2,$3,$4::uuid)`, a.tenantID, keyID, publicKeyBase64, a.userID); err != nil {
return err
}
} else if err != nil {
return err
} else if existingPublicKey != publicKeyBase64 || status != "ACTIVE" {
return ErrFirmwareSigningKeyConflict
}
fingerprint := sha256.Sum256(decoded)
return s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "firmware.signing_key.register", ResourceType: "firmware_signing_key", RequestID: requestID, Result: "success", Detail: map[string]any{"key_id": keyID, "fingerprint_sha256": hex.EncodeToString(fingerprint[:])}})
})
}
func (s *Service) ListFirmwareSigningKeys(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareSigningKey, error) {
access, err := resolveHouseholdAccess(ctx, s.pool, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return nil, err
}
return s.listFirmwareSigningKeys(ctx, access.tenantID)
}
func (s *Service) listFirmwareSigningKeys(ctx context.Context, tenantID string) ([]FirmwareSigningKey, error) {
rows, err := s.pool.Query(ctx, `
SELECT k.key_id,k.public_key_base64,k.status,creator.public_id::text,k.created_at,k.revoked_at,
(SELECT count(*) FROM firmware_release fr WHERE fr.tenant_id=k.tenant_id AND fr.signing_key_id=k.key_id AND fr.status='UPLOADING'),
(SELECT count(*) FROM firmware_release fr WHERE fr.tenant_id=k.tenant_id AND fr.signing_key_id=k.key_id AND fr.status='READY'),
(SELECT count(*) FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id AND fr.tenant_id=ro.tenant_id WHERE ro.tenant_id=k.tenant_id AND fr.signing_key_id=k.key_id AND ro.status='RUNNING')
FROM firmware_signing_key k JOIN user_account creator ON creator.id=k.created_by AND creator.tenant_id=k.tenant_id
WHERE k.tenant_id=$1::uuid ORDER BY k.created_at DESC,k.key_id`, tenantID)
if err != nil {
return nil, fmt.Errorf("list firmware signing keys: %w", err)
}
defer rows.Close()
items := make([]FirmwareSigningKey, 0)
for rows.Next() {
var item FirmwareSigningKey
var publicKeyBase64 string
if err := rows.Scan(&item.KeyID, &publicKeyBase64, &item.Status, &item.CreatedBy, &item.CreatedAt, &item.RevokedAt, &item.UploadingReleases, &item.ReadyReleases, &item.RunningRollouts); err != nil {
return nil, fmt.Errorf("scan firmware signing key: %w", err)
}
decoded, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil || len(decoded) != ed25519.PublicKeySize {
return nil, ErrFirmwareSigningKeyConflict
}
fingerprint := sha256.Sum256(decoded)
item.FingerprintSHA256 = hex.EncodeToString(fingerprint[:])
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate firmware signing keys: %w", err)
}
return items, nil
}
func (s *Service) RevokeFirmwareSigningKey(ctx context.Context, p identity.Principal, householdPublicID, requestID, keyID string) (FirmwareSigningKey, error) {
var tenantID string
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return err
}
tenantID = access.tenantID
var status string
if err := tx.QueryRow(ctx, `SELECT status FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 FOR UPDATE`, tenantID, keyID).Scan(&status); errors.Is(err, pgx.ErrNoRows) {
return ErrFirmwareSigningKeyConflict
} else if err != nil {
return err
}
if status == "REVOKED" {
return nil
}
var blocked bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM firmware_release fr WHERE fr.tenant_id=$1::uuid AND fr.signing_key_id=$2 AND fr.status='UPLOADING'
UNION ALL
SELECT 1 FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id AND fr.tenant_id=ro.tenant_id WHERE ro.tenant_id=$1::uuid AND fr.signing_key_id=$2 AND ro.status='RUNNING'
)`, tenantID, keyID).Scan(&blocked); err != nil {
return err
}
if blocked {
return ErrFirmwareSigningKeyInUse
}
command, err := tx.Exec(ctx, `UPDATE firmware_signing_key SET status='REVOKED',revoked_at=now() WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE'`, tenantID, keyID)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrFirmwareSigningKeyConflict
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "firmware.signing_key.revoke", ResourceType: "firmware_signing_key", RequestID: requestID, Result: "success", Detail: map[string]any{"key_id": keyID}})
})
if err != nil {
return FirmwareSigningKey{}, err
}
items, err := s.listFirmwareSigningKeys(ctx, tenantID)
if err != nil {
return FirmwareSigningKey{}, err
}
for _, item := range items {
if item.KeyID == keyID {
return item, nil
}
}
return FirmwareSigningKey{}, ErrFirmwareSigningKeyConflict
}
func (s *Service) PrepareFirmwareRelease(ctx context.Context, p identity.Principal, householdPublicID, requestID string, in PrepareFirmwareInput) (FirmwareRelease, error) {
if s.firmwareStore == nil {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
var item FirmwareRelease
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return err
}
var signingKeyActive bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE')`, a.tenantID, in.SigningKeyID).Scan(&signingKeyActive); err != nil {
return err
}
if !signingKeyActive {
return ErrFirmwareSigningKeyInactive
}
tenantHash := sha256.Sum256([]byte(a.tenantID))
objectRef := fmt.Sprintf("tenants/%s/firmware/%s/%s/%s.bin", hex.EncodeToString(tenantHash[:16]), safePath(in.Model), safePath(in.HardwareRevision), safePath(in.Version))
err = tx.QueryRow(ctx, `INSERT INTO firmware_release(tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,created_by) VALUES($1::uuid,$2::uuid,$3,NULLIF($4,''),$5,$6,$7,$8,$9,$10,NULLIF($11,''),NULLIF($12,''),$13::uuid) RETURNING public_id::text,model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),status,verified_at,created_at,updated_at,object_ref`, a.tenantID, a.householdID, in.Model, in.HardwareRevision, in.Version, objectRef, in.ExpectedSize, in.ExpectedDigest, in.Signature, in.SigningKeyID, in.MinimumSecurityVersion, in.ReleaseNotes, a.userID).Scan(firmwareReleaseTargets(&item)...)
if err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "firmware.release.prepare", ResourceType: "firmware_release", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"model": in.Model, "version": in.Version, "digest": in.ExpectedDigest}})
})
if err != nil {
return FirmwareRelease{}, err
}
url, err := s.firmwareStore.PresignUpload(ctx, item.objectRef, 15*time.Minute)
if err != nil {
return FirmwareRelease{}, err
}
until := time.Now().Add(15 * time.Minute)
item.UploadURL = url
item.UploadUntil = &until
return item, nil
}
func safePath(v string) string {
v = strings.TrimSpace(v)
r := strings.NewReplacer("/", "-", "\\", "-", "..", "-")
return r.Replace(v)
}
func firmwareReleaseTargets(i *FirmwareRelease) []any {
return []any{&i.ID, &i.Model, &i.HardwareRevision, &i.Version, &i.ExpectedSize, &i.ExpectedDigest, &i.SignatureAlgorithm, &i.Signature, &i.SigningKeyID, &i.MinimumSecurityVersion, &i.ReleaseNotes, &i.Status, &i.VerifiedAt, &i.CreatedAt, &i.UpdatedAt, &i.objectRef}
}
func (s *Service) ListFirmwareReleases(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareRelease, error) {
items := []FirmwareRelease{}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT public_id::text,model,COALESCE(hardware_revision,''),version,expected_size,expected_digest,signature_algorithm,signature,signing_key_id,COALESCE(minimum_security_version,''),COALESCE(release_notes,''),status,verified_at,created_at,updated_at,object_ref FROM firmware_release WHERE tenant_id=$1::uuid AND household_id=$2::uuid ORDER BY created_at DESC LIMIT 100`, a.tenantID, a.householdID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var item FirmwareRelease
if err := rows.Scan(firmwareReleaseTargets(&item)...); err != nil {
return err
}
items = append(items, item)
}
return rows.Err()
})
return items, err
}
func (s *Service) GetFirmwareReleaseImpact(ctx context.Context, p identity.Principal, publicID string) (FirmwareReleaseImpact, error) {
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
if err != nil {
return FirmwareReleaseImpact{}, err
}
result := FirmwareReleaseImpact{ReleaseID: item.ID, ReleaseStatus: item.Status, ObservationDevices: []FirmwareImpactDevice{}, CalculatedAt: time.Now().UTC()}
err = s.pool.QueryRow(ctx, `
SELECT
count(DISTINCT ro.id),
count(DISTINCT ro.id) FILTER(WHERE ro.status='RUNNING'),
count(DISTINCT ro.id) FILTER(WHERE ro.status='PAUSED'),
count(DISTINCT ro.id) FILTER(WHERE ro.status='COMPLETED'),
count(DISTINCT ro.id) FILTER(WHERE ro.status='ROLLED_BACK'),
count(fa.id),
count(fa.id) FILTER(WHERE fa.status='ASSIGNED'),
count(fa.id) FILTER(WHERE fa.status='DOWNLOADING'),
count(fa.id) FILTER(WHERE fa.status='INSTALLING'),
count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),
count(fa.id) FILTER(WHERE fa.status='FAILED'),
count(fa.id) FILTER(WHERE fa.status='ROLLED_BACK'),
count(fa.id) FILTER(WHERE fa.status IN ('DOWNLOADING','INSTALLING') OR (fa.status='ASSIGNED' AND dc.status IN ('DELIVERED','ACKNOWLEDGED','SUCCEEDED','FAILED'))),
count(dc.id),
count(dc.id) FILTER(WHERE dc.status='QUEUED'),
count(dc.id) FILTER(WHERE dc.status='DELIVERED'),
count(dc.id) FILTER(WHERE dc.status='ACKNOWLEDGED'),
count(dc.id) FILTER(WHERE dc.status='SUCCEEDED'),
count(dc.id) FILTER(WHERE dc.status='FAILED'),
count(dc.id) FILTER(WHERE dc.status='EXPIRED'),
count(dc.id) FILTER(WHERE dc.status='CANCELLED')
FROM firmware_release fr
LEFT JOIN firmware_rollout ro ON ro.release_id=fr.id AND ro.tenant_id=fr.tenant_id
LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id AND fa.tenant_id=fr.tenant_id
LEFT JOIN device_command dc ON dc.id=fa.command_id AND dc.tenant_id=fr.tenant_id
WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid
GROUP BY fr.id`, access.tenantID, publicID).Scan(
&result.Rollouts.Total, &result.Rollouts.Running, &result.Rollouts.Paused, &result.Rollouts.Completed, &result.Rollouts.RolledBack,
&result.Devices.Total, &result.Devices.Assigned, &result.Devices.Downloading, &result.Devices.Installing, &result.Devices.Confirmed, &result.Devices.Failed, &result.Devices.RolledBack, &result.Devices.Observing,
&result.Commands.Total, &result.Commands.Queued, &result.Commands.Delivered, &result.Commands.Acknowledged, &result.Commands.Succeeded, &result.Commands.Failed, &result.Commands.Expired, &result.Commands.CancelledBeforeDelivery,
)
if err != nil {
return FirmwareReleaseImpact{}, fmt.Errorf("summarize firmware release impact: %w", err)
}
rows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,d.model,ro.public_id::text,ro.name,ro.status,fa.status,fa.progress_percent,COALESCE(dc.status,'UNKNOWN'),fa.last_reported_at,fa.updated_at
FROM firmware_release fr
JOIN firmware_rollout ro ON ro.release_id=fr.id AND ro.tenant_id=fr.tenant_id
JOIN firmware_assignment fa ON fa.rollout_id=ro.id AND fa.tenant_id=fr.tenant_id
JOIN device d ON d.id=fa.device_id AND d.tenant_id=fr.tenant_id
LEFT JOIN device_command dc ON dc.id=fa.command_id AND dc.tenant_id=fr.tenant_id
WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid
AND (fa.status IN ('DOWNLOADING','INSTALLING') OR (fa.status='ASSIGNED' AND dc.status IN ('DELIVERED','ACKNOWLEDGED','SUCCEEDED','FAILED')))
ORDER BY CASE fa.status WHEN 'INSTALLING' THEN 1 WHEN 'DOWNLOADING' THEN 2 ELSE 3 END,fa.updated_at DESC,d.public_id
LIMIT 101`, access.tenantID, publicID)
if err != nil {
return FirmwareReleaseImpact{}, fmt.Errorf("list firmware observation devices: %w", err)
}
defer rows.Close()
for rows.Next() {
var observation FirmwareImpactDevice
if err := rows.Scan(&observation.DeviceID, &observation.DeviceModel, &observation.RolloutID, &observation.RolloutName, &observation.RolloutStatus, &observation.AssignmentStatus, &observation.ProgressPercent, &observation.CommandStatus, &observation.LastReportedAt, &observation.UpdatedAt); err != nil {
return FirmwareReleaseImpact{}, fmt.Errorf("scan firmware observation device: %w", err)
}
observation.ObservationReason = firmwareObservationReason(observation.AssignmentStatus, observation.CommandStatus)
result.ObservationDevices = append(result.ObservationDevices, observation)
}
if err := rows.Err(); err != nil {
return FirmwareReleaseImpact{}, fmt.Errorf("iterate firmware observation devices: %w", err)
}
if len(result.ObservationDevices) > 100 {
result.ObservationDevices = result.ObservationDevices[:100]
result.ObservationDevicesTruncated = true
}
return result, nil
}
func firmwareObservationReason(assignmentStatus, commandStatus string) string {
switch assignmentStatus {
case "INSTALLING":
return "INSTALLATION_IN_PROGRESS"
case "DOWNLOADING":
return "DOWNLOAD_IN_PROGRESS"
}
switch commandStatus {
case "DELIVERED":
return "COMMAND_DELIVERED"
case "ACKNOWLEDGED":
return "COMMAND_ACKNOWLEDGED"
default:
return "RESULT_RECONCILIATION_REQUIRED"
}
}
func (s *Service) FinalizeFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
item, a, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
if err != nil {
return FirmwareRelease{}, err
}
if item.Status == "READY" {
return item, nil
}
if item.Status != "UPLOADING" {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
if err := s.firmwareStore.VerifyDigest(ctx, item.objectRef, item.ExpectedDigest, item.ExpectedSize); err != nil {
return FirmwareRelease{}, err
}
var publicKeyBase64 string
if err := s.pool.QueryRow(ctx, `SELECT public_key_base64 FROM firmware_signing_key WHERE tenant_id=$1::uuid AND key_id=$2 AND status='ACTIVE'`, a.tenantID, item.SigningKeyID).Scan(&publicKeyBase64); err != nil {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
publicKey, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
signature, err := base64.StdEncoding.DecodeString(item.Signature)
if err != nil {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(firmwareManifest(item.Model, item.HardwareRevision, item.Version, item.ExpectedDigest, item.ExpectedSize)), signature) {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='READY',verified_at=now(),updated_at=now() WHERE public_id=$1::uuid AND status='UPLOADING'`, publicID)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrFirmwareStateConflict
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "firmware.release.ready", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"digest": item.ExpectedDigest, "size": item.ExpectedSize}})
})
if err != nil {
return FirmwareRelease{}, err
}
item.Status = "READY"
now := time.Now()
item.VerifiedAt = &now
return item, nil
}
func (s *Service) RenewFirmwareUpload(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
if s.firmwareStore == nil {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
if err != nil {
return FirmwareRelease{}, err
}
if item.Status != "UPLOADING" {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
url, err := s.firmwareStore.PresignUpload(ctx, item.objectRef, 15*time.Minute)
if err != nil {
return FirmwareRelease{}, err
}
until := time.Now().Add(15 * time.Minute)
if err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var currentStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM firmware_release WHERE public_id=$1::uuid FOR SHARE`, publicID).Scan(&currentStatus); err != nil {
return err
}
if currentStatus != "UPLOADING" {
return ErrFirmwareStateConflict
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "firmware.release.upload_renew", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"upload_until": until}})
}); err != nil {
return FirmwareRelease{}, err
}
item.UploadURL = url
item.UploadUntil = &until
return item, nil
}
func (s *Service) PauseFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
if err != nil {
return FirmwareRelease{}, err
}
if item.Status == "PAUSED" {
return item, nil
}
if item.Status != "READY" {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var releaseID, status string
if err := tx.QueryRow(ctx, `SELECT id::text,status FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, access.tenantID, publicID).Scan(&releaseID, &status); err != nil {
return err
}
if status == "PAUSED" {
return nil
}
if status != "READY" {
return ErrFirmwareStateConflict
}
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='PAUSED',updated_at=now() WHERE id=$1::uuid AND status='READY'`, releaseID)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrFirmwareStateConflict
}
paused, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE release_id=$1::uuid AND status='RUNNING'`, releaseID)
if err != nil {
return err
}
cancelled, err := tx.Exec(ctx, `UPDATE device_command dc SET status='CANCELLED',completed_at=now(),version=version+1,updated_at=now() FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE fa.command_id=dc.id AND ro.release_id=$1::uuid AND ro.status='PAUSED' AND dc.status='QUEUED'`, releaseID)
if err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "firmware.release.pause", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": "READY", "paused_rollouts": paused.RowsAffected(), "cancelled_commands": cancelled.RowsAffected()}})
})
if err != nil {
return FirmwareRelease{}, err
}
return s.currentFirmwareRelease(ctx, p, publicID)
}
func (s *Service) ResumeFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
if err != nil {
return FirmwareRelease{}, err
}
if item.Status == "READY" {
return item, nil
}
if item.Status != "PAUSED" {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var status, keyStatus string
err := tx.QueryRow(ctx, `SELECT fr.status,fsk.status FROM firmware_release fr JOIN firmware_signing_key fsk ON fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id WHERE fr.tenant_id=$1::uuid AND fr.public_id=$2::uuid FOR UPDATE OF fr,fsk`, access.tenantID, publicID).Scan(&status, &keyStatus)
if errors.Is(err, pgx.ErrNoRows) {
return ErrFirmwareSigningKeyInactive
}
if err != nil {
return err
}
if status == "READY" {
return nil
}
if status != "PAUSED" {
return ErrFirmwareStateConflict
}
if keyStatus != "ACTIVE" {
return ErrFirmwareSigningKeyInactive
}
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='READY',updated_at=now() WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='PAUSED'`, access.tenantID, publicID)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrFirmwareStateConflict
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "firmware.release.resume", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": "PAUSED", "rollouts_resumed": 0}})
})
if err != nil {
return FirmwareRelease{}, err
}
return s.currentFirmwareRelease(ctx, p, publicID)
}
func (s *Service) currentFirmwareRelease(ctx context.Context, p identity.Principal, publicID string) (FirmwareRelease, error) {
item, _, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
return item, err
}
func (s *Service) RetireFirmwareRelease(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRelease, error) {
item, access, err := s.resolveFirmwareRelease(ctx, p, publicID, true)
if err != nil {
return FirmwareRelease{}, err
}
if item.Status != "UPLOADING" && item.Status != "READY" && item.Status != "PAUSED" && item.Status != "RETIRED" {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
removeObject := item.Status == "UPLOADING" || item.Status == "RETIRED" && item.VerifiedAt == nil
if item.Status != "RETIRED" {
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var releaseID, status string
var verifiedAt *time.Time
if err := tx.QueryRow(ctx, `SELECT id::text,status,verified_at FROM firmware_release WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, access.tenantID, publicID).Scan(&releaseID, &status, &verifiedAt); err != nil {
return err
}
if status == "RETIRED" {
removeObject = verifiedAt == nil
return nil
}
if status != "UPLOADING" && status != "READY" && status != "PAUSED" {
return ErrFirmwareStateConflict
}
if status == "READY" || status == "PAUSED" {
var running bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM firmware_rollout WHERE release_id=$1::uuid AND status='RUNNING')`, releaseID).Scan(&running); err != nil {
return err
}
if running {
return ErrFirmwareReleaseInUse
}
removeObject = false
}
command, err := tx.Exec(ctx, `UPDATE firmware_release SET status='RETIRED',updated_at=now() WHERE id=$1::uuid AND status=$2`, releaseID, status)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrFirmwareStateConflict
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "firmware.release.retire", ResourceType: "firmware_release", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": status, "object_preserved": verifiedAt != nil}})
})
if err != nil {
return FirmwareRelease{}, err
}
item.Status = "RETIRED"
item.UpdatedAt = time.Now()
}
if removeObject {
if s.firmwareStore == nil {
return FirmwareRelease{}, ErrFirmwareStateConflict
}
if err := s.firmwareStore.Remove(ctx, item.objectRef); err != nil {
return FirmwareRelease{}, fmt.Errorf("remove retired firmware object: %w", err)
}
}
return item, nil
}
func firmwareManifest(model, hardware, version, digest string, size int64) string {
return fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", model, hardware, version, digest, size)
}
func (s *Service) resolveFirmwareRelease(ctx context.Context, p identity.Principal, publicID string, manage bool) (FirmwareRelease, resourceAccess, error) {
cap := identity.CapabilityDeviceManage
if manage {
cap = identity.CapabilityFirmwareManage
}
var item FirmwareRelease
var a resourceAccess
var role string
err := s.pool.QueryRow(ctx, `SELECT fr.public_id::text,fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_size,fr.expected_digest,fr.signature_algorithm,fr.signature,fr.signing_key_id,COALESCE(fr.minimum_security_version,''),COALESCE(fr.release_notes,''),fr.status,fr.verified_at,fr.created_at,fr.updated_at,fr.object_ref,fr.tenant_id::text,ua.id::text,ua.public_id::text,hm.role,h.id::text,h.public_id::text FROM firmware_release fr JOIN household h ON h.id=fr.household_id AND h.tenant_id=fr.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=fr.tenant_id AND ua.status='active' WHERE fr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 LIMIT 1`, publicID, p.Issuer, p.Subject).Scan(append(firmwareReleaseTargets(&item), &a.tenantID, &a.userID, &a.userPublicID, &role, &a.householdID, &a.householdPublicID)...)
if errors.Is(err, pgx.ErrNoRows) {
return FirmwareRelease{}, resourceAccess{}, ErrAccessDenied
}
if err != nil {
return FirmwareRelease{}, resourceAccess{}, err
}
a.role = identity.Role(role)
if !identity.Can(a.role, cap) {
return FirmwareRelease{}, resourceAccess{}, ErrAccessDenied
}
return item, a, nil
}
func (s *Service) StartFirmwareRollout(ctx context.Context, p identity.Principal, householdPublicID, releasePublicID, requestID, name string, percentage int, deviceIDs []string) (FirmwareRollout, error) {
sort.Strings(deviceIDs)
var result FirmwareRollout
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return err
}
var releaseID, model, hardware, version, digest, signature, keyID string
err = tx.QueryRow(ctx, `SELECT fr.id::text,fr.model,COALESCE(fr.hardware_revision,''),fr.version,fr.expected_digest,fr.signature,fr.signing_key_id FROM firmware_release fr JOIN firmware_signing_key fsk ON fsk.tenant_id=fr.tenant_id AND fsk.key_id=fr.signing_key_id AND fsk.status='ACTIVE' WHERE fr.tenant_id=$1::uuid AND fr.household_id=$2::uuid AND fr.public_id=$3::uuid AND fr.status='READY' FOR SHARE OF fr,fsk`, a.tenantID, a.householdID, releasePublicID).Scan(&releaseID, &model, &hardware, &version, &digest, &signature, &keyID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrFirmwareStateConflict
}
if err != nil {
return err
}
var rolloutID string
err = tx.QueryRow(ctx, `INSERT INTO firmware_rollout(tenant_id,household_id,release_id,name,status,target_percentage,created_by,started_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4,'RUNNING',$5,$6::uuid,now()) RETURNING id::text,public_id::text,created_at,updated_at`, a.tenantID, a.householdID, releaseID, name, percentage, a.userID).Scan(&rolloutID, &result.ID, &result.CreatedAt, &result.UpdatedAt)
if err != nil {
return err
}
result.ReleaseID = releasePublicID
result.HouseholdID = householdPublicID
result.Name = name
result.Status = "RUNNING"
result.TargetPercentage = percentage
selected := 0
for _, devicePublicID := range deviceIDs {
h := sha256.Sum256([]byte(devicePublicID))
if int(h[0])%100 >= percentage && selected > 0 {
continue
}
var deviceID, previous string
err := tx.QueryRow(ctx, `SELECT d.id::text,COALESCE(dt.firmware_version,'') FROM device d JOIN device_twin dt ON dt.device_id=d.id JOIN device_binding b ON b.device_id=d.id AND b.household_id=$2::uuid AND b.unbound_at IS NULL WHERE d.tenant_id=$1::uuid AND d.public_id=$3::uuid AND d.model=$4 AND ($5='' OR d.hardware_revision=$5)`, a.tenantID, a.householdID, devicePublicID, model, hardware).Scan(&deviceID, &previous)
if err != nil {
return fmt.Errorf("rollout target %s is incompatible or inaccessible", devicePublicID)
}
payload, _ := json.Marshal(map[string]any{"release_id": releasePublicID, "version": version, "sha256": digest, "signature": signature, "signing_key_id": keyID, "download_path": "/device/v2/firmware-releases/" + releasePublicID + "/download"})
var commandID string
dedupe := "firmware-rollout:" + rolloutID + ":" + deviceID
requestDigest := sha256.Sum256(payload)
err = tx.QueryRow(ctx, `INSERT INTO device_command(tenant_id,device_id,command_type,payload,requested_by,idempotency_key,request_digest,expires_at) VALUES($1::uuid,$2::uuid,'firmware_update',$3::jsonb,$4::uuid,$5,$6,now()+interval '24 hours') RETURNING id::text`, a.tenantID, deviceID, payload, a.userID, dedupe, hex.EncodeToString(requestDigest[:])).Scan(&commandID)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO firmware_assignment(rollout_id,tenant_id,device_id,command_id,previous_version,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,NULLIF($5,''),'ASSIGNED')`, rolloutID, a.tenantID, deviceID, commandID, previous); err != nil {
return err
}
selected++
}
if selected == 0 {
return errors.New("rollout selected no devices")
}
result.Targeted = selected
return s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "firmware.rollout.start", ResourceType: "firmware_rollout", ResourceID: &result.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"release_id": releasePublicID, "targeted": selected, "percentage": percentage}})
})
return result, err
}
func (s *Service) GetFirmwareRollout(ctx context.Context, p identity.Principal, publicID string) (FirmwareRollout, error) {
var item FirmwareRollout
var role string
var targeted, succeeded, failed int64
err := s.pool.QueryRow(ctx, `SELECT ro.public_id::text,fr.public_id::text,h.public_id::text,ro.name,ro.status,ro.target_percentage,count(fa.id),count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),count(fa.id) FILTER(WHERE fa.status='FAILED'),ro.created_at,ro.updated_at,hm.role FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id JOIN household h ON h.id=ro.household_id LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=ro.tenant_id AND ua.status='active' WHERE ro.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 GROUP BY ro.id,fr.public_id,h.public_id,hm.role`, publicID, p.Issuer, p.Subject).Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt, &role)
if errors.Is(err, pgx.ErrNoRows) {
return FirmwareRollout{}, ErrAccessDenied
}
if err != nil {
return FirmwareRollout{}, err
}
if !identity.Can(identity.Role(role), identity.CapabilityFirmwareManage) {
return FirmwareRollout{}, ErrAccessDenied
}
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
assignments, err := s.listFirmwareAssignments(ctx, publicID)
if err != nil {
return FirmwareRollout{}, err
}
item.Assignments = assignments
return item, nil
}
func (s *Service) ListFirmwareRollouts(ctx context.Context, p identity.Principal, householdPublicID string) ([]FirmwareRollout, error) {
items := []FirmwareRollout{}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
a, err := resolveHouseholdAccess(ctx, tx, p, householdPublicID, identity.CapabilityFirmwareManage, true)
if err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT ro.public_id::text,fr.public_id::text,h.public_id::text,ro.name,ro.status,ro.target_percentage,count(fa.id),count(fa.id) FILTER(WHERE fa.status='CONFIRMED'),count(fa.id) FILTER(WHERE fa.status='FAILED'),ro.created_at,ro.updated_at FROM firmware_rollout ro JOIN firmware_release fr ON fr.id=ro.release_id JOIN household h ON h.id=ro.household_id LEFT JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.tenant_id=$1::uuid AND ro.household_id=$2::uuid GROUP BY ro.id,fr.public_id,h.public_id ORDER BY ro.created_at DESC LIMIT 100`, a.tenantID, a.householdID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var item FirmwareRollout
var targeted, succeeded, failed int64
if err := rows.Scan(&item.ID, &item.ReleaseID, &item.HouseholdID, &item.Name, &item.Status, &item.TargetPercentage, &targeted, &succeeded, &failed, &item.CreatedAt, &item.UpdatedAt); err != nil {
return err
}
item.Targeted, item.Succeeded, item.Failed = int(targeted), int(succeeded), int(failed)
items = append(items, item)
}
return rows.Err()
})
return items, err
}
func (s *Service) listFirmwareAssignments(ctx context.Context, rolloutPublicID string) ([]FirmwareAssignment, error) {
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.model,COALESCE(fa.previous_version,''),fa.status,fa.progress_percent,fa.last_result,fa.last_reported_at,fa.updated_at FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id JOIN device d ON d.id=fa.device_id WHERE ro.public_id=$1::uuid ORDER BY d.public_id`, rolloutPublicID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FirmwareAssignment{}
itemIndexes := map[string]int{}
for rows.Next() {
var item FirmwareAssignment
var resultJSON []byte
if err := rows.Scan(&item.DeviceID, &item.DeviceModel, &item.PreviousVersion, &item.Status, &item.ProgressPercent, &resultJSON, &item.LastReportedAt, &item.UpdatedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(resultJSON, &item.LastResult); err != nil {
return nil, err
}
item.Events = []FirmwareAssignmentEvent{}
itemIndexes[item.DeviceID] = len(items)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close()
eventRows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,e.id::text,dc.public_id::text,e.command_status,e.assignment_status,e.phase,e.progress_percent,e.result_code,
e.active_slot,e.target_slot,e.previous_slot,e.failed_slot,e.boot_attempt_id,e.boot_health_deadline,e.rollback_applied,e.rollback_version,
e.installed_version,e.verified_sha256,e.verified_signing_key_id,e.result_digest,e.reported_at
FROM firmware_assignment_event e
JOIN firmware_assignment fa ON fa.id=e.assignment_id
JOIN firmware_rollout ro ON ro.id=fa.rollout_id
JOIN device d ON d.id=fa.device_id
JOIN device_command dc ON dc.id=e.command_id
WHERE ro.public_id=$1::uuid
ORDER BY d.public_id,e.reported_at,e.id`, rolloutPublicID)
if err != nil {
return nil, err
}
defer eventRows.Close()
for eventRows.Next() {
var deviceID string
var event FirmwareAssignmentEvent
if err := eventRows.Scan(&deviceID, &event.ID, &event.CommandID, &event.CommandStatus, &event.AssignmentStatus, &event.Phase, &event.ProgressPercent, &event.ResultCode,
&event.ActiveSlot, &event.TargetSlot, &event.PreviousSlot, &event.FailedSlot, &event.BootAttemptID, &event.BootHealthDeadline, &event.RollbackApplied, &event.RollbackVersion,
&event.InstalledVersion, &event.VerifiedSHA256, &event.VerifiedSigningKeyID, &event.ResultDigest, &event.ReportedAt); err != nil {
return nil, err
}
if index, ok := itemIndexes[deviceID]; ok {
items[index].Events = append(items[index].Events, event)
}
}
return items, eventRows.Err()
}
func (s *Service) PauseFirmwareRollout(ctx context.Context, p identity.Principal, publicID, requestID string) (FirmwareRollout, error) {
item, err := s.GetFirmwareRollout(ctx, p, publicID)
if err != nil {
return FirmwareRollout{}, err
}
if item.Status != "RUNNING" {
return FirmwareRollout{}, ErrFirmwareStateConflict
}
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var tenantID, userPublicID string
err := tx.QueryRow(ctx, `SELECT ro.tenant_id::text,ua.public_id::text FROM firmware_rollout ro JOIN household_member hm ON hm.household_id=ro.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=ro.tenant_id WHERE ro.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND hm.role IN ('owner','admin') FOR UPDATE OF ro`, publicID, p.Issuer, p.Subject).Scan(&tenantID, &userPublicID)
if err != nil {
return ErrAccessDenied
}
if _, err := tx.Exec(ctx, `UPDATE firmware_rollout SET status='PAUSED',updated_at=now() WHERE public_id=$1::uuid AND status='RUNNING'`, publicID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE device_command dc SET status='CANCELLED',completed_at=now(),version=version+1,updated_at=now() FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE fa.command_id=dc.id AND ro.public_id=$1::uuid AND dc.status='QUEUED'`, publicID); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "user", ActorPublicID: &userPublicID, Action: "firmware.rollout.pause", ResourceType: "firmware_rollout", ResourceID: &publicID, RequestID: requestID, Result: "success"})
})
if err != nil {
return FirmwareRollout{}, err
}
return s.GetFirmwareRollout(ctx, p, publicID)
}
func (s *Service) StartFirmwareRollback(ctx context.Context, p identity.Principal, rolloutPublicID, rollbackReleaseID, requestID string) (FirmwareRollout, error) {
original, err := s.GetFirmwareRollout(ctx, p, rolloutPublicID)
if err != nil {
return FirmwareRollout{}, err
}
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text FROM firmware_assignment fa JOIN firmware_rollout ro ON ro.id=fa.rollout_id JOIN device d ON d.id=fa.device_id WHERE ro.public_id=$1::uuid ORDER BY d.public_id`, rolloutPublicID)
if err != nil {
return FirmwareRollout{}, err
}
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return FirmwareRollout{}, err
}
ids = append(ids, id)
}
rows.Close()
if original.Status == "RUNNING" {
if _, err := s.PauseFirmwareRollout(ctx, p, rolloutPublicID, requestID+"-pause"); err != nil {
return FirmwareRollout{}, err
}
}
return s.StartFirmwareRollout(ctx, p, original.HouseholdID, rollbackReleaseID, requestID, "回滚:"+original.Name, 100, ids)
}
func (s *Service) FirmwareDownloadForDevice(ctx context.Context, id deviceauth.Identity, releasePublicID string) (string, time.Time, error) {
if s.firmwareStore == nil {
return "", time.Time{}, ErrFirmwareStateConflict
}
var objectRef, version string
err := s.pool.QueryRow(ctx, `SELECT fr.object_ref,fr.version FROM firmware_release fr JOIN firmware_rollout ro ON ro.release_id=fr.id AND ro.status IN ('RUNNING','COMPLETED') JOIN firmware_assignment fa ON fa.rollout_id=ro.id AND fa.device_id=$2::uuid WHERE fr.public_id=$1::uuid AND fr.tenant_id=$3::uuid AND fr.status='READY' AND fa.status IN ('ASSIGNED','DOWNLOADING','INSTALLING','CONFIRMED')`, releasePublicID, id.ID, id.TenantID).Scan(&objectRef, &version)
if err != nil {
return "", time.Time{}, ErrAccessDenied
}
ttl := s.firmwareURLTTL
if ttl <= 0 {
ttl = 5 * time.Minute
}
url, err := s.firmwareStore.PresignDownload(ctx, objectRef, ttl, "firmware-"+safePath(version)+".bin")
return url, time.Now().Add(ttl), err
}
@@ -0,0 +1,316 @@
package saas
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type firmwareIntegrationStore struct {
verifiedObject string
verifiedDigest string
verifiedSize int64
presigned []string
removed []string
}
func (s *firmwareIntegrationStore) PresignUpload(_ context.Context, objectRef string, _ time.Duration) (string, error) {
s.presigned = append(s.presigned, objectRef)
return "https://objects.invalid/upload/" + objectRef, nil
}
func (s *firmwareIntegrationStore) PresignDownload(_ context.Context, objectRef string, _ time.Duration, filename string) (string, error) {
return "https://objects.invalid/download/" + objectRef + "?filename=" + filename, nil
}
func (s *firmwareIntegrationStore) VerifyDigest(_ context.Context, objectRef, digest string, size int64) error {
s.verifiedObject, s.verifiedDigest, s.verifiedSize = objectRef, digest, size
return nil
}
func (s *firmwareIntegrationStore) Remove(_ context.Context, objectRef string) error {
s.removed = append(s.removed, objectRef)
return nil
}
func TestFirmwareReleaseAndRolloutWriteLifecycle(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()
store := &firmwareIntegrationStore{}
service := NewService(pool, audit.NewRepository(), nil, nil)
service.ConfigureFirmwareStore(store, 4*time.Minute)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
householdID := "10000000-0000-0000-0000-000000000211"
devicePublicID := "10000000-0000-0000-0000-000000000511"
keyID := "firmware-integration-" + uuid.NewString()
version := "integration-" + uuid.NewString()
var releaseID, retiredReleaseID, replacementReleaseID, rolloutID string
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'firmware-integration-%'`)
if rolloutID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM firmware_rollout WHERE public_id=$1::uuid`, rolloutID)
}
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE payload->>'release_id'=$1`, releaseID)
if releaseID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, releaseID)
}
if retiredReleaseID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, retiredReleaseID)
}
if replacementReleaseID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, replacementReleaseID)
}
_, _ = pool.Exec(ctx, `DELETE FROM firmware_signing_key WHERE key_id=$1`, keyID)
}()
if err := service.RegisterFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-invalid-key", keyID, "invalid"); err == nil {
t.Fatal("invalid signing key was accepted")
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKey)
if err := service.RegisterFirmwareSigningKey(ctx, outsider, householdID, "firmware-integration-outsider-key", keyID, publicKeyBase64); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider signing key error=%v", err)
}
if err := service.RegisterFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-key", keyID, publicKeyBase64); err != nil {
t.Fatalf("register signing key: %v", err)
}
if err := service.RegisterFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-key-replay", keyID, publicKeyBase64); err != nil {
t.Fatalf("replay same signing key: %v", err)
}
otherPublicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
if err := service.RegisterFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-key-overwrite", keyID, base64.StdEncoding.EncodeToString(otherPublicKey)); !errors.Is(err, ErrFirmwareSigningKeyConflict) {
t.Fatalf("overwrite signing key error=%v", err)
}
keys, err := service.ListFirmwareSigningKeys(ctx, owner, householdID)
expectedFingerprint := sha256.Sum256(publicKey)
if err != nil || len(keys) != 1 || keys[0].KeyID != keyID || keys[0].Status != "ACTIVE" || keys[0].FingerprintSHA256 != hex.EncodeToString(expectedFingerprint[:]) {
t.Fatalf("signing keys=%+v err=%v", keys, err)
}
firmwareBytes := []byte("synthetic signed firmware image")
digestBytes := sha256.Sum256(firmwareBytes)
digest := hex.EncodeToString(digestBytes[:])
model, hardware := "simulated-terminal", "sim-rev-a"
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(firmwareManifest(model, hardware, version, digest, int64(len(firmwareBytes))))))
release, err := service.PrepareFirmwareRelease(ctx, owner, householdID, "firmware-integration-prepare", PrepareFirmwareInput{
Model: model, HardwareRevision: hardware, Version: version, ExpectedDigest: digest,
Signature: signature, SigningKeyID: keyID, MinimumSecurityVersion: "1", ReleaseNotes: "合成固件集成测试",
ExpectedSize: int64(len(firmwareBytes)),
})
if err != nil || release.Status != "UPLOADING" || release.UploadURL == "" || release.UploadUntil == nil {
t.Fatalf("prepared release=%+v err=%v", release, err)
}
releaseID = release.ID
if _, err := service.RevokeFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-revoke-uploading", keyID); !errors.Is(err, ErrFirmwareSigningKeyInUse) {
t.Fatalf("revoke uploading signing key error=%v", err)
}
keys, err = service.ListFirmwareSigningKeys(ctx, owner, householdID)
if err != nil || len(keys) != 1 || keys[0].UploadingReleases != 1 {
t.Fatalf("uploading key references=%+v err=%v", keys, err)
}
if !strings.Contains(release.UploadURL, "/firmware/simulated-terminal/sim-rev-a/") {
t.Fatalf("unexpected upload URL %q", release.UploadURL)
}
if _, err := service.RenewFirmwareUpload(ctx, outsider, release.ID, "firmware-integration-outsider-renew"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider renew upload error=%v", err)
}
renewed, err := service.RenewFirmwareUpload(ctx, owner, release.ID, "firmware-integration-renew")
if err != nil || renewed.ID != release.ID || renewed.Status != "UPLOADING" || renewed.UploadURL == "" || renewed.UploadUntil == nil || len(store.presigned) != 2 {
t.Fatalf("renewed release=%+v presigned=%v err=%v", renewed, store.presigned, err)
}
releases, err := service.ListFirmwareReleases(ctx, owner, householdID)
if err != nil || len(releases) != 1 || releases[0].ID != release.ID {
t.Fatalf("firmware releases=%+v err=%v", releases, err)
}
if _, err := service.FinalizeFirmwareRelease(ctx, outsider, release.ID, "firmware-integration-outsider-finalize"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider finalize error=%v", err)
}
release, err = service.FinalizeFirmwareRelease(ctx, owner, release.ID, "firmware-integration-finalize")
if err != nil || release.Status != "READY" || release.VerifiedAt == nil {
t.Fatalf("finalized release=%+v err=%v", release, err)
}
if store.verifiedDigest != digest || store.verifiedSize != int64(len(firmwareBytes)) || store.verifiedObject == "" {
t.Fatalf("object verification=%+v", store)
}
replayedRelease, err := service.FinalizeFirmwareRelease(ctx, owner, release.ID, "firmware-integration-finalize-replay")
if err != nil || replayedRelease.Status != "READY" {
t.Fatalf("replayed finalize=%+v err=%v", replayedRelease, err)
}
if _, err := service.RenewFirmwareUpload(ctx, owner, release.ID, "firmware-integration-renew-ready"); !errors.Is(err, ErrFirmwareStateConflict) {
t.Fatalf("renew READY release error=%v", err)
}
if _, err := service.PauseFirmwareRelease(ctx, outsider, release.ID, "firmware-integration-outsider-release-pause"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider pause release error=%v", err)
}
release, err = service.PauseFirmwareRelease(ctx, owner, release.ID, "firmware-integration-release-pause")
if err != nil || release.Status != "PAUSED" {
t.Fatalf("paused release=%+v err=%v", release, err)
}
if replayed, err := service.PauseFirmwareRelease(ctx, owner, release.ID, "firmware-integration-release-pause-replay"); err != nil || replayed.Status != "PAUSED" {
t.Fatalf("replayed release pause=%+v err=%v", replayed, err)
}
if _, err := service.StartFirmwareRollout(ctx, owner, householdID, release.ID, "firmware-integration-paused-release-rollout", "暂停版本禁止下发", 100, []string{devicePublicID}); !errors.Is(err, ErrFirmwareStateConflict) {
t.Fatalf("rollout with paused release error=%v", err)
}
release, err = service.ResumeFirmwareRelease(ctx, owner, release.ID, "firmware-integration-release-resume")
if err != nil || release.Status != "READY" {
t.Fatalf("resumed release=%+v err=%v", release, err)
}
if replayed, err := service.ResumeFirmwareRelease(ctx, owner, release.ID, "firmware-integration-release-resume-replay"); err != nil || replayed.Status != "READY" {
t.Fatalf("replayed release resume=%+v err=%v", replayed, err)
}
retireVersion := "retire-" + uuid.NewString()
retireSignature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(firmwareManifest(model, hardware, retireVersion, digest, int64(len(firmwareBytes))))))
retiring, err := service.PrepareFirmwareRelease(ctx, owner, householdID, "firmware-integration-prepare-retire", PrepareFirmwareInput{Model: model, HardwareRevision: hardware, Version: retireVersion, ExpectedDigest: digest, Signature: retireSignature, SigningKeyID: keyID, ExpectedSize: int64(len(firmwareBytes))})
if err != nil {
t.Fatalf("prepare retiring release: %v", err)
}
retiredReleaseID = retiring.ID
retired, err := service.RetireFirmwareRelease(ctx, owner, retiring.ID, "firmware-integration-retire")
if err != nil || retired.Status != "RETIRED" || len(store.removed) != 1 || store.removed[0] == "" {
t.Fatalf("retired release=%+v removed=%v err=%v", retired, store.removed, err)
}
if replayed, err := service.RetireFirmwareRelease(ctx, owner, retiring.ID, "firmware-integration-retire-replay"); err != nil || replayed.Status != "RETIRED" || len(store.removed) != 2 {
t.Fatalf("replayed retire=%+v removed=%v err=%v", replayed, store.removed, err)
}
if _, err := service.FinalizeFirmwareRelease(ctx, owner, retiring.ID, "firmware-integration-finalize-retired"); !errors.Is(err, ErrFirmwareStateConflict) {
t.Fatalf("finalize retired release error=%v", err)
}
replacement, err := service.PrepareFirmwareRelease(ctx, owner, householdID, "firmware-integration-prepare-replacement", PrepareFirmwareInput{Model: model, HardwareRevision: hardware, Version: retireVersion, ExpectedDigest: digest, Signature: retireSignature, SigningKeyID: keyID, ExpectedSize: int64(len(firmwareBytes))})
if err != nil || replacement.Status != "UPLOADING" || replacement.ID == retiring.ID {
t.Fatalf("replacement release=%+v err=%v", replacement, err)
}
replacementReleaseID = replacement.ID
if _, err := service.RetireFirmwareRelease(ctx, owner, replacement.ID, "firmware-integration-retire-replacement"); err != nil {
t.Fatalf("retire replacement release: %v", err)
}
if _, err := service.StartFirmwareRollout(ctx, outsider, householdID, release.ID, "firmware-integration-outsider-rollout", "禁止下发", 100, []string{devicePublicID}); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider rollout error=%v", err)
}
rollout, err := service.StartFirmwareRollout(ctx, owner, householdID, release.ID, "firmware-integration-rollout", "全量合成下发", 100, []string{devicePublicID})
if err != nil || rollout.Status != "RUNNING" || rollout.Targeted != 1 {
t.Fatalf("rollout=%+v err=%v", rollout, err)
}
rolloutID = rollout.ID
rollout, err = service.GetFirmwareRollout(ctx, owner, rollout.ID)
if err != nil || len(rollout.Assignments) != 1 || rollout.Assignments[0].DeviceID != devicePublicID || rollout.Assignments[0].Status != "ASSIGNED" {
t.Fatalf("rollout detail=%+v err=%v", rollout, err)
}
rollouts, err := service.ListFirmwareRollouts(ctx, owner, householdID)
if err != nil || len(rollouts) != 1 || rollouts[0].ID != rollout.ID {
t.Fatalf("rollouts=%+v err=%v", rollouts, err)
}
impact, err := service.GetFirmwareReleaseImpact(ctx, owner, release.ID)
if err != nil || impact.ReleaseStatus != "READY" || impact.Rollouts.Total != 1 || impact.Rollouts.Running != 1 || impact.Devices.Total != 1 || impact.Devices.Assigned != 1 || impact.Commands.Queued != 1 || impact.Devices.Observing != 0 {
t.Fatalf("initial release impact=%+v err=%v", impact, err)
}
if _, err := service.GetFirmwareReleaseImpact(ctx, outsider, release.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider release impact error=%v", err)
}
downloadURL, expiresAt, err := service.FirmwareDownloadForDevice(ctx, deviceauth.Identity{
ID: "10000000-0000-0000-0000-000000000501", TenantID: "10000000-0000-0000-0000-000000000001",
}, release.ID)
if err != nil || downloadURL == "" || time.Until(expiresAt) <= 0 {
t.Fatalf("firmware download url=%q expires=%s err=%v", downloadURL, expiresAt, err)
}
observationCommandID := uuid.NewString()
if _, err := pool.Exec(ctx, `INSERT INTO device_command(id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at,delivered_at,acknowledged_at) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000502'::uuid,'firmware_update',jsonb_build_object('release_id',$2::text),'ACKNOWLEDGED','10000000-0000-0000-0000-000000000101'::uuid,$3,repeat('a',64),now()+interval '1 hour',now(),now())`, observationCommandID, release.ID, "firmware-impact-"+uuid.NewString()); err != nil {
t.Fatalf("insert observation command: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO firmware_assignment(rollout_id,tenant_id,device_id,command_id,status,progress_percent,last_result,last_reported_at) SELECT ro.id,ro.tenant_id,'10000000-0000-0000-0000-000000000502'::uuid,$2::uuid,'INSTALLING',72,'{"phase":"installing","progress_percent":72}'::jsonb,now() FROM firmware_rollout ro WHERE ro.public_id=$1::uuid`, rollout.ID, observationCommandID); err != nil {
t.Fatalf("insert observation assignment: %v", err)
}
impact, err = service.GetFirmwareReleaseImpact(ctx, owner, release.ID)
if err != nil || impact.Devices.Total != 2 || impact.Devices.Installing != 1 || impact.Devices.Observing != 1 || impact.Commands.Queued != 1 || impact.Commands.Acknowledged != 1 || len(impact.ObservationDevices) != 1 || impact.ObservationDevices[0].ObservationReason != "INSTALLATION_IN_PROGRESS" || impact.ObservationDevices[0].ProgressPercent == nil || *impact.ObservationDevices[0].ProgressPercent != 72 {
t.Fatalf("active release impact=%+v err=%v", impact, err)
}
if _, err := service.RetireFirmwareRelease(ctx, owner, release.ID, "firmware-integration-retire-running"); !errors.Is(err, ErrFirmwareReleaseInUse) {
t.Fatalf("retire release with running rollout error=%v", err)
}
if _, err := service.RevokeFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-revoke-running", keyID); !errors.Is(err, ErrFirmwareSigningKeyInUse) {
t.Fatalf("revoke key with running rollout error=%v", err)
}
release, err = service.PauseFirmwareRelease(ctx, owner, release.ID, "firmware-integration-release-pause-running")
if err != nil || release.Status != "PAUSED" {
t.Fatalf("paused running release=%+v err=%v", release, err)
}
rollout, err = service.GetFirmwareRollout(ctx, owner, rollout.ID)
if err != nil || rollout.Status != "PAUSED" {
t.Fatalf("rollout not paused with release=%+v err=%v", rollout, err)
}
impact, err = service.GetFirmwareReleaseImpact(ctx, owner, release.ID)
if err != nil || impact.ReleaseStatus != "PAUSED" || impact.Rollouts.Running != 0 || impact.Rollouts.Paused != 1 || impact.Commands.CancelledBeforeDelivery != 1 || impact.Commands.Acknowledged != 1 || impact.Devices.Observing != 1 || len(impact.ObservationDevices) != 1 {
t.Fatalf("paused release impact=%+v err=%v", impact, err)
}
var cancelledCommands int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM device_command WHERE payload->>'release_id'=$1 AND status='CANCELLED'`, release.ID).Scan(&cancelledCommands); err != nil || cancelledCommands != 1 {
t.Fatalf("release pause cancelled commands=%d err=%v", cancelledCommands, err)
}
if _, _, err := service.FirmwareDownloadForDevice(ctx, deviceauth.Identity{
ID: "10000000-0000-0000-0000-000000000501", TenantID: "10000000-0000-0000-0000-000000000001",
}, release.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("paused rollout download error=%v", err)
}
if _, err := service.RevokeFirmwareSigningKey(ctx, outsider, householdID, "firmware-integration-outsider-revoke", keyID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider revoke signing key error=%v", err)
}
revokedKey, err := service.RevokeFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-revoke", keyID)
if err != nil || revokedKey.Status != "REVOKED" || revokedKey.RevokedAt == nil || revokedKey.ReadyReleases != 0 || revokedKey.RunningRollouts != 0 {
t.Fatalf("revoked signing key=%+v err=%v", revokedKey, err)
}
if replayedKey, err := service.RevokeFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-revoke-replay", keyID); err != nil || replayedKey.Status != "REVOKED" {
t.Fatalf("replayed key revoke=%+v err=%v", replayedKey, err)
}
if err := service.RegisterFirmwareSigningKey(ctx, owner, householdID, "firmware-integration-reactivate", keyID, publicKeyBase64); !errors.Is(err, ErrFirmwareSigningKeyConflict) {
t.Fatalf("reactivate revoked signing key error=%v", err)
}
if _, err := service.ResumeFirmwareRelease(ctx, owner, release.ID, "firmware-integration-resume-revoked-key"); !errors.Is(err, ErrFirmwareSigningKeyInactive) {
t.Fatalf("resume release with revoked signing key error=%v", err)
}
removedBeforeVerifiedRetire := len(store.removed)
release, err = service.RetireFirmwareRelease(ctx, owner, release.ID, "firmware-integration-retire-verified")
if err != nil || release.Status != "RETIRED" || len(store.removed) != removedBeforeVerifiedRetire {
t.Fatalf("retired verified release=%+v removed=%v err=%v", release, store.removed, err)
}
if replayed, err := service.RetireFirmwareRelease(ctx, owner, release.ID, "firmware-integration-retire-verified-replay"); err != nil || replayed.Status != "RETIRED" || len(store.removed) != removedBeforeVerifiedRetire {
t.Fatalf("replayed verified retire=%+v removed=%v err=%v", replayed, store.removed, err)
}
if _, err := service.StartFirmwareRollout(ctx, owner, householdID, release.ID, "firmware-integration-revoked-key-rollout", "吊销后禁止", 100, []string{devicePublicID}); !errors.Is(err, ErrFirmwareStateConflict) {
t.Fatalf("rollout with revoked signing key error=%v", err)
}
if _, err := service.PrepareFirmwareRelease(ctx, owner, householdID, "firmware-integration-revoked-key-prepare", PrepareFirmwareInput{Model: model, HardwareRevision: hardware, Version: "revoked-" + uuid.NewString(), ExpectedDigest: digest, Signature: signature, SigningKeyID: keyID, ExpectedSize: int64(len(firmwareBytes))}); !errors.Is(err, ErrFirmwareSigningKeyInactive) {
t.Fatalf("prepare with revoked signing key error=%v", err)
}
}
+445
View File
@@ -0,0 +1,445 @@
package saas
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
var (
ErrInvitationInvalid = errors.New("member invitation is invalid")
ErrMemberExists = errors.New("active household member already exists")
ErrOwnerProtected = errors.New("household owner must be transferred first")
)
type HouseholdMember struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Role identity.Role `json:"role"`
Status string `json:"status"`
JoinedAt time.Time `json:"joined_at"`
RemovedAt *time.Time `json:"removed_at,omitempty"`
Version int64 `json:"version"`
IsOwner bool `json:"is_owner"`
}
type MemberInvitation struct {
ID string `json:"id"`
HouseholdID string `json:"household_id"`
InviteeHint string `json:"invitee_hint,omitempty"`
Role identity.Role `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
Token string `json:"token,omitempty"`
}
type MemberInvitationPreview struct {
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
HouseholdID string `json:"household_id"`
HouseholdName string `json:"household_name"`
InviteeHint string `json:"invitee_hint,omitempty"`
Role identity.Role `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
}
func (s *Service) PreviewMemberInvitation(ctx context.Context, token string) (MemberInvitationPreview, error) {
digest := sha256.Sum256([]byte(strings.TrimSpace(token)))
var result MemberInvitationPreview
var usedAt, revokedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT t.public_id::text,t.name,h.public_id::text,h.name,COALESCE(mi.invitee_hint,''),mi.role,mi.expires_at,mi.used_at,mi.revoked_at
FROM member_invitation mi
JOIN household h ON h.id=mi.household_id AND h.tenant_id=mi.tenant_id AND h.status='active'
JOIN tenant t ON t.id=mi.tenant_id AND t.status='active'
WHERE mi.token_digest=$1`, hex.EncodeToString(digest[:])).Scan(&result.TenantID, &result.TenantName, &result.HouseholdID, &result.HouseholdName, &result.InviteeHint, &result.Role, &result.ExpiresAt, &usedAt, &revokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return MemberInvitationPreview{}, ErrInvitationInvalid
}
if err != nil {
return MemberInvitationPreview{}, fmt.Errorf("preview member invitation: %w", err)
}
if usedAt != nil || revokedAt != nil || !result.ExpiresAt.After(time.Now()) {
return MemberInvitationPreview{}, ErrInvitationInvalid
}
return result, nil
}
func (s *Service) ListHouseholdMembers(ctx context.Context, principal identity.Principal, householdPublicID string) ([]HouseholdMember, error) {
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilityHouseholdRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT hm.public_id::text, ua.public_id::text, ua.display_name, hm.role, hm.status,
hm.joined_at, hm.removed_at, hm.version, hm.user_id=h.owner_user_id
FROM household_member hm
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=hm.tenant_id
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id
WHERE hm.tenant_id=$1::uuid AND hm.household_id=$2::uuid AND hm.status='active'
ORDER BY (hm.user_id=h.owner_user_id) DESC, hm.joined_at, hm.public_id`, access.tenantID, access.householdID)
if err != nil {
return nil, fmt.Errorf("list household members: %w", err)
}
defer rows.Close()
items := make([]HouseholdMember, 0)
for rows.Next() {
item, err := scanHouseholdMember(rows)
if err != nil {
return nil, fmt.Errorf("scan household member: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate household members: %w", err)
}
return items, nil
}
func (s *Service) ListMemberInvitations(ctx context.Context, principal identity.Principal, householdPublicID string) ([]MemberInvitation, error) {
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilityMemberManage, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT public_id::text,$1::text,COALESCE(invitee_hint,''),role,expires_at,used_at,revoked_at,created_at
FROM member_invitation WHERE tenant_id=$2::uuid AND household_id=$3::uuid
ORDER BY created_at DESC, public_id`, householdPublicID, access.tenantID, access.householdID)
if err != nil {
return nil, fmt.Errorf("list member invitations: %w", err)
}
defer rows.Close()
items := make([]MemberInvitation, 0)
for rows.Next() {
item, err := scanInvitation(rows)
if err != nil {
return nil, fmt.Errorf("scan member invitation: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate member invitations: %w", err)
}
return items, nil
}
func (s *Service) CreateMemberInvitation(ctx context.Context, principal identity.Principal, householdPublicID, inviteeHint string, role identity.Role, expiresAt time.Time, requestID string) (MemberInvitation, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return MemberInvitation{}, fmt.Errorf("generate invitation token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
digest := sha256.Sum256([]byte(token))
var result MemberInvitation
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
if err != nil {
return err
}
row := tx.QueryRow(ctx, `
INSERT INTO member_invitation (tenant_id,household_id,invitee_hint,token_digest,role,expires_at,created_by)
VALUES ($1::uuid,$2::uuid,NULLIF($3,''),$4,$5,$6,$7::uuid)
RETURNING public_id::text,$8::text,COALESCE(invitee_hint,''),role,expires_at,used_at,revoked_at,created_at`,
access.tenantID, access.householdID, inviteeHint, hex.EncodeToString(digest[:]), role, expiresAt, access.userID, householdPublicID)
result, err = scanInvitation(row)
if err != nil {
return fmt.Errorf("insert member invitation: %w", err)
}
result.Token = token
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "member.invitation.create", ResourceType: "member_invitation", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "role": role, "expires_at": expiresAt},
})
})
return result, err
}
func (s *Service) RevokeMemberInvitation(ctx context.Context, principal identity.Principal, householdPublicID, invitationPublicID, requestID string) (MemberInvitation, error) {
var result MemberInvitation
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
if err != nil {
return err
}
row := tx.QueryRow(ctx, `
UPDATE member_invitation SET revoked_at=COALESCE(revoked_at,now())
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND used_at IS NULL
RETURNING public_id::text,$4::text,COALESCE(invitee_hint,''),role,expires_at,used_at,revoked_at,created_at`,
invitationPublicID, access.tenantID, access.householdID, householdPublicID)
result, err = scanInvitation(row)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("revoke member invitation: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "member.invitation.revoke", ResourceType: "member_invitation", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID},
})
})
return result, err
}
func (s *Service) AcceptMemberInvitation(ctx context.Context, principal identity.Principal, token, requestID string) (HouseholdMember, bool, error) {
digest := sha256.Sum256([]byte(token))
var result HouseholdMember
var replayed bool
var failureTenantID, failureUserPublicID, failureInvitationPublicID, failureHouseholdPublicID string
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var invitationID, invitationPublicID, tenantID, householdID, householdPublicID, role string
var expiresAt time.Time
var usedAt, revokedAt *time.Time
err := tx.QueryRow(ctx, `
SELECT mi.id::text,mi.public_id::text,mi.tenant_id::text,mi.household_id::text,h.public_id::text,mi.role,mi.expires_at,mi.used_at,mi.revoked_at
FROM member_invitation mi JOIN household h ON h.id=mi.household_id AND h.tenant_id=mi.tenant_id AND h.status='active'
WHERE mi.token_digest=$1 FOR UPDATE OF mi`, hex.EncodeToString(digest[:])).Scan(
&invitationID, &invitationPublicID, &tenantID, &householdID, &householdPublicID, &role, &expiresAt, &usedAt, &revokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvitationInvalid
}
if err != nil {
return fmt.Errorf("resolve member invitation: %w", err)
}
var userID, userPublicID string
if err := tx.QueryRow(ctx, `
SELECT id::text,public_id::text FROM user_account
WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active'`,
tenantID, principal.Issuer, principal.Subject).Scan(&userID, &userPublicID); errors.Is(err, pgx.ErrNoRows) {
displayName := strings.TrimSpace(principal.DisplayName)
if displayName == "" {
displayName = strings.TrimSpace(principal.PreferredUsername)
}
if displayName == "" {
displayName = "受邀家庭成员"
}
err = tx.QueryRow(ctx, `
INSERT INTO user_account (tenant_id,oidc_issuer,oidc_subject,display_name,status,last_login_at)
VALUES ($1::uuid,$2,$3,$4,'active',now())
ON CONFLICT (tenant_id,oidc_issuer,oidc_subject) DO NOTHING
RETURNING id::text,public_id::text`, tenantID, principal.Issuer, principal.Subject, displayName).Scan(&userID, &userPublicID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
}
if err != nil {
return fmt.Errorf("create invited tenant account: %w", err)
}
} else if err != nil {
return fmt.Errorf("resolve invitation account: %w", err)
}
failureTenantID, failureUserPublicID = tenantID, userPublicID
failureInvitationPublicID, failureHouseholdPublicID = invitationPublicID, householdPublicID
if usedAt != nil {
return ErrInvitationInvalid
}
if revokedAt != nil || !expiresAt.After(time.Now()) {
return ErrInvitationInvalid
}
var active bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM household_member WHERE household_id=$1::uuid AND user_id=$2::uuid AND status='active')`, householdID, userID).Scan(&active); err != nil {
return fmt.Errorf("check existing household member: %w", err)
}
if active {
return ErrMemberExists
}
row := tx.QueryRow(ctx, `
INSERT INTO household_member (tenant_id,household_id,user_id,role,status)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'active')
RETURNING public_id::text,$5::text,(SELECT display_name FROM user_account WHERE id=$3::uuid),role,status,joined_at,removed_at,version,false`,
tenantID, householdID, userID, role, userPublicID)
result, err = scanHouseholdMember(row)
if err != nil {
return fmt.Errorf("insert invited household member: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE member_invitation SET used_at=now() WHERE id=$1::uuid`, invitationID); err != nil {
return fmt.Errorf("consume member invitation: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID),
Action: "member.invitation.accept", ResourceType: "household_member", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "role": role},
})
})
if errors.Is(err, ErrInvitationInvalid) && failureTenantID != "" {
auditErr := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(failureTenantID), ActorType: "user", ActorPublicID: strPtr(failureUserPublicID),
Action: "member.invitation.accept", ResourceType: "member_invitation", ResourceID: strPtr(failureInvitationPublicID),
RequestID: requestID, Result: "failure", Detail: map[string]any{"household_id": failureHouseholdPublicID, "reason": "invalid_expired_revoked_or_used"},
})
})
if auditErr != nil {
return HouseholdMember{}, false, fmt.Errorf("record failed invitation acceptance: %w", auditErr)
}
}
return result, replayed, err
}
func (s *Service) PatchHouseholdMemberRole(ctx context.Context, principal identity.Principal, householdPublicID, memberPublicID string, role identity.Role, expectedVersion int64, requestID string) (HouseholdMember, error) {
var result HouseholdMember
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
if err != nil {
return err
}
var targetID, targetUserID, currentRole string
var version int64
if err := tx.QueryRow(ctx, `SELECT id::text,user_id::text,role,version FROM household_member WHERE public_id=$1::uuid AND household_id=$2::uuid AND tenant_id=$3::uuid AND status='active' FOR UPDATE`, memberPublicID, access.householdID, access.tenantID).Scan(&targetID, &targetUserID, &currentRole, &version); errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return fmt.Errorf("resolve household member: %w", err)
}
if currentRole == string(identity.RoleOwner) {
return ErrOwnerProtected
}
if version != expectedVersion {
return ErrVersionConflict
}
row := tx.QueryRow(ctx, `
UPDATE household_member hm SET role=$1,version=hm.version+1,updated_at=now()
FROM user_account ua, household h
WHERE hm.id=$2::uuid AND ua.id=hm.user_id AND h.id=hm.household_id
RETURNING hm.public_id::text,ua.public_id::text,ua.display_name,hm.role,hm.status,hm.joined_at,hm.removed_at,hm.version,hm.user_id=h.owner_user_id`, role, targetID)
result, err = scanHouseholdMember(row)
if err != nil {
return fmt.Errorf("update household member role: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "household_member.role.update", ResourceType: "household_member", ResourceID: strPtr(result.ID), RequestID: requestID,
Result: "success", Detail: map[string]any{"household_id": householdPublicID, "from_role": currentRole, "to_role": role},
})
})
return result, err
}
func (s *Service) RemoveHouseholdMember(ctx context.Context, principal identity.Principal, householdPublicID, memberPublicID string, expectedVersion int64, requestID string) (HouseholdMember, error) {
var result HouseholdMember
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityMemberManage, true)
if err != nil {
return err
}
row := tx.QueryRow(ctx, `
SELECT hm.public_id::text,ua.public_id::text,ua.display_name,hm.role,hm.status,hm.joined_at,hm.removed_at,hm.version,hm.user_id=h.owner_user_id
FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id JOIN household h ON h.id=hm.household_id
WHERE hm.public_id=$1::uuid AND hm.household_id=$2::uuid AND hm.tenant_id=$3::uuid AND hm.status='active' FOR UPDATE OF hm`, memberPublicID, access.householdID, access.tenantID)
current, err := scanHouseholdMember(row)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("resolve household member removal: %w", err)
}
if current.IsOwner {
return ErrOwnerProtected
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
row = tx.QueryRow(ctx, `
UPDATE household_member hm SET status='removed',removed_at=now(),version=hm.version+1,updated_at=now()
FROM user_account ua WHERE hm.public_id=$1::uuid AND ua.id=hm.user_id
RETURNING hm.public_id::text,ua.public_id::text,ua.display_name,hm.role,hm.status,hm.joined_at,hm.removed_at,hm.version,false`, memberPublicID)
result, err = scanHouseholdMember(row)
if err != nil {
return fmt.Errorf("remove household member: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "household_member.remove", ResourceType: "household_member", ResourceID: strPtr(result.ID), RequestID: requestID,
Result: "success", Detail: map[string]any{"household_id": householdPublicID, "role": current.Role},
})
})
return result, err
}
func (s *Service) TransferHouseholdOwnership(ctx context.Context, principal identity.Principal, householdPublicID, targetMemberPublicID string, expectedVersion int64, requestID string) (Household, error) {
var result Household
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityOwnershipTransfer, true)
if err != nil {
return err
}
if access.version != expectedVersion {
return ErrVersionConflict
}
var targetUserID, targetRole string
if err := tx.QueryRow(ctx, `SELECT user_id::text,role FROM household_member WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status='active' FOR UPDATE`, targetMemberPublicID, access.tenantID, access.householdID).Scan(&targetUserID, &targetRole); errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return fmt.Errorf("resolve ownership target: %w", err)
}
if targetUserID == access.userID {
return ErrMemberExists
}
if _, err := tx.Exec(ctx, `UPDATE household_member SET role='admin',version=version+1,updated_at=now() WHERE household_id=$1::uuid AND user_id=$2::uuid AND status='active'`, access.householdID, access.userID); err != nil {
return fmt.Errorf("demote former household owner: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE household_member SET role='owner',version=version+1,updated_at=now() WHERE household_id=$1::uuid AND user_id=$2::uuid AND status='active'`, access.householdID, targetUserID); err != nil {
return fmt.Errorf("promote household owner: %w", err)
}
if err := tx.QueryRow(ctx, `
UPDATE household SET owner_user_id=$1::uuid,version=version+1,updated_at=now() WHERE id=$2::uuid
RETURNING public_id::text,$3::text,name,status,version,created_at,updated_at`, targetUserID, access.householdID, access.tenantPublicID).Scan(
&result.ID, &result.TenantID, &result.Name, &result.Status, &result.Version, &result.CreatedAt, &result.UpdatedAt); err != nil {
return fmt.Errorf("transfer household ownership: %w", err)
}
result.Role = identity.RoleAdmin
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "household.ownership.transfer", ResourceType: "household", ResourceID: strPtr(result.ID), RequestID: requestID,
Result: "success", Detail: map[string]any{"target_member_id": targetMemberPublicID, "target_previous_role": targetRole, "version": result.Version},
})
})
return result, err
}
func scanHouseholdMember(row rowScanner) (HouseholdMember, error) {
var item HouseholdMember
var role string
if err := row.Scan(&item.ID, &item.UserID, &item.DisplayName, &role, &item.Status, &item.JoinedAt, &item.RemovedAt, &item.Version, &item.IsOwner); err != nil {
return HouseholdMember{}, err
}
item.Role = identity.Role(role)
return item, nil
}
func scanInvitation(row rowScanner) (MemberInvitation, error) {
var item MemberInvitation
var role string
if err := row.Scan(&item.ID, &item.HouseholdID, &item.InviteeHint, &role, &item.ExpiresAt, &item.UsedAt, &item.RevokedAt, &item.CreatedAt); err != nil {
return MemberInvitation{}, err
}
item.Role = identity.Role(role)
switch {
case item.UsedAt != nil:
item.Status = "used"
case item.RevokedAt != nil:
item.Status = "revoked"
case !item.ExpiresAt.After(time.Now()):
item.Status = "expired"
default:
item.Status = "pending"
}
return item, nil
}
+273
View File
@@ -0,0 +1,273 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
)
var ErrIdempotencyConflict = errors.New("idempotency key was reused with different request data")
type PrivacySubtask struct {
ID string `json:"id"`
Subsystem string `json:"subsystem"`
TargetType string `json:"target_type"`
TargetRef string `json:"target_ref"`
Status string `json:"status"`
Attempts int `json:"attempts"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
LastError string `json:"last_error,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type PrivacyRequest struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
RequestType string `json:"request_type"`
Scope map[string]any `json:"scope"`
Status string `json:"status"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
LegalHold bool `json:"legal_hold"`
ResultSummary map[string]any `json:"result_summary"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Subtasks []PrivacySubtask `json:"subtasks"`
}
type CreatePrivacyRequestInput struct {
RequestType string `json:"request_type"`
Scope map[string]any `json:"scope"`
Reason string `json:"-"`
Confirmation string `json:"-"`
}
func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.Principal, subjectPublicID, idempotencyKey, requestID string, input CreatePrivacyRequestInput) (PrivacyRequest, bool, error) {
input.RequestType = strings.ToUpper(strings.TrimSpace(input.RequestType))
input.Reason = strings.TrimSpace(input.Reason)
input.Confirmation = strings.TrimSpace(input.Confirmation)
if input.Scope == nil {
input.Scope = map[string]any{}
}
if input.RequestType != "ACCESS" && input.RequestType != "DELETE" {
return PrivacyRequest{}, false, ErrNotFound
}
if input.RequestType == "DELETE" {
reasonDigest := sha256.Sum256([]byte(input.Reason))
input.Scope = map[string]any{"reason_digest": hex.EncodeToString(reasonDigest[:])}
}
confirmationDigest := ""
if input.RequestType == "DELETE" {
value := sha256.Sum256([]byte(input.Confirmation))
confirmationDigest = hex.EncodeToString(value[:])
}
requestBytes, err := json.Marshal(struct {
RequestType string `json:"request_type"`
Scope map[string]any `json:"scope"`
ConfirmationDigest string `json:"confirmation_digest,omitempty"`
}{input.RequestType, input.Scope, confirmationDigest})
if err != nil {
return PrivacyRequest{}, false, fmt.Errorf("encode privacy request: %w", err)
}
digestValue := sha256.Sum256(requestBytes)
digest := hex.EncodeToString(digestValue[:])
created := false
var publicID string
err = database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
// Idempotent replay remains readable after the first DELETE request has
// frozen the subject and removed it from normal resource resolution.
var replayDigest string
replayErr := tx.QueryRow(ctx, `
SELECT pr.public_id::text,pr.request_digest
FROM privacy_request pr
JOIN subject_profile sp ON sp.id=pr.subject_id AND sp.tenant_id=pr.tenant_id
JOIN household_member hm ON hm.household_id=sp.household_id AND hm.tenant_id=sp.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=sp.tenant_id AND ua.status='active'
WHERE sp.public_id=$1::uuid AND pr.idempotency_key=$2
AND ua.oidc_issuer=$3 AND ua.oidc_subject=$4 AND pr.requested_by=ua.id`,
subjectPublicID, idempotencyKey, principal.Issuer, principal.Subject).Scan(&publicID, &replayDigest)
if replayErr == nil {
if replayDigest != digest {
return ErrIdempotencyConflict
}
return nil
}
if !errors.Is(replayErr, pgx.ErrNoRows) {
return replayErr
}
access, subject, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilityPrivacyManage, true)
if err != nil {
return err
}
if input.RequestType == "DELETE" {
if input.Confirmation != subject.PreferredName {
return ErrDeleteConfirmation
}
var activeCount int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM conversation WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')`, access.tenantID, access.subjectID).Scan(&activeCount); err != nil {
return err
}
if activeCount > 0 {
return ErrSubjectDeleteConflict
}
}
scopeJSON, err := json.Marshal(input.Scope)
if err != nil {
return err
}
var internalID string
err = tx.QueryRow(ctx, `
INSERT INTO privacy_request(tenant_id,subject_id,request_type,scope,requested_by,idempotency_key,request_digest)
VALUES($1::uuid,$2::uuid,$3,$4::jsonb,$5::uuid,$6,$7)
ON CONFLICT (tenant_id,requested_by,idempotency_key) DO NOTHING
RETURNING id::text,public_id::text`, access.tenantID, access.subjectID, input.RequestType, scopeJSON, access.userID, idempotencyKey, digest,
).Scan(&internalID, &publicID)
if errors.Is(err, pgx.ErrNoRows) {
var storedDigest string
if err := tx.QueryRow(ctx, `SELECT public_id::text,request_digest FROM privacy_request WHERE tenant_id=$1::uuid AND requested_by=$2::uuid AND idempotency_key=$3 FOR UPDATE`, access.tenantID, access.userID, idempotencyKey).Scan(&publicID, &storedDigest); err != nil {
return fmt.Errorf("read idempotent privacy request: %w", err)
}
if storedDigest != digest {
return ErrIdempotencyConflict
}
return nil
}
if err != nil {
return fmt.Errorf("create privacy request: %w", err)
}
created = true
eventType := "privacy.access_requested"
action := "privacy.access.request"
if input.RequestType == "ACCESS" {
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref) VALUES($1::uuid,'postgresql','subject_data_inventory',$2)`, internalID, subjectPublicID); err != nil {
return fmt.Errorf("create privacy subtask: %w", err)
}
} else {
tasks := [][2]string{{"redis", "ephemeral_keys"}, {"export_files", "private_exports"}, {"object_storage", "subject_media"}, {"pgvector", "memory_embeddings"}, {"provider_index", "external_memory_index"}, {"postgresql", "subject_personal_data"}}
for _, task := range tasks {
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref) VALUES($1::uuid,$2,$3,$4)`, internalID, task[0], task[1], subjectPublicID); err != nil {
return fmt.Errorf("create deletion subtask %s: %w", task[0], err)
}
}
if _, err := tx.Exec(ctx, `UPDATE subject_profile SET status='deleting',profile_version=profile_version+1,updated_by=$3::uuid,updated_at=now() WHERE tenant_id=$1::uuid AND id=$2::uuid`, access.tenantID, access.subjectID, access.userID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE consent_record SET withdrawn_at=COALESCE(withdrawn_at,GREATEST(now(),granted_at)),scope='{}'::jsonb,evidence_ref=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`, access.tenantID, access.subjectID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET status='DISABLED',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status NOT IN ('DELETED','DISABLED')`, access.tenantID, access.subjectID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='subject privacy deletion requested',updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status<>'DELETED'`, access.tenantID, access.subjectID); err != nil {
return err
}
eventType = "privacy.delete_requested"
action = "privacy.delete.request"
}
dedupe := strings.ReplaceAll(eventType, ".", "-") + ":" + internalID
payload, _ := json.Marshal(map[string]any{"request_id": internalID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "privacy_request", AggregateID: internalID, EventType: eventType, DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: action, ResourceType: "privacy_request", ResourceID: strPtr(publicID), RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "scope": input.Scope}})
})
if err != nil {
return PrivacyRequest{}, false, err
}
result, err := s.GetPrivacyRequest(ctx, principal, publicID)
return result, created, err
}
func (s *Service) ListPrivacyRequests(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]PrivacyRequest, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilityPrivacyRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, privacyRequestSelectSQL+` WHERE pr.tenant_id=$1::uuid AND pr.subject_id=$2::uuid ORDER BY pr.created_at DESC,pr.id DESC`, access.tenantID, access.subjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]PrivacyRequest, 0)
for rows.Next() {
item, err := scanPrivacyRequest(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetPrivacyRequest(ctx context.Context, principal identity.Principal, publicID string) (PrivacyRequest, error) {
item, err := scanPrivacyRequest(s.pool.QueryRow(ctx, privacyRequestSelectSQL+`
JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id AND h.status<>'deleted'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE pr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject))
if errors.Is(err, pgx.ErrNoRows) {
return PrivacyRequest{}, ErrAccessDenied
}
if err != nil {
return PrivacyRequest{}, err
}
var role string
if err := s.pool.QueryRow(ctx, `SELECT hm.role FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id JOIN household_member hm ON hm.household_id=sp.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=pr.tenant_id AND ua.status='active' WHERE pr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject).Scan(&role); err != nil {
return PrivacyRequest{}, ErrAccessDenied
}
if !identity.Can(identity.Role(role), identity.CapabilityPrivacyRead) {
return PrivacyRequest{}, ErrAccessDenied
}
if err := s.loadPrivacySubtasks(ctx, &item); err != nil {
return PrivacyRequest{}, err
}
return item, nil
}
const privacyRequestSelectSQL = `SELECT pr.public_id::text,sp.public_id::text,pr.request_type,pr.scope,pr.status,pr.deadline_at,pr.legal_hold,pr.result_summary,pr.version,pr.created_at,pr.updated_at,pr.completed_at FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id AND sp.tenant_id=pr.tenant_id`
func scanPrivacyRequest(row rowScanner) (PrivacyRequest, error) {
var item PrivacyRequest
var scopeJSON, resultJSON []byte
if err := row.Scan(&item.ID, &item.SubjectID, &item.RequestType, &scopeJSON, &item.Status, &item.DeadlineAt, &item.LegalHold, &resultJSON, &item.Version, &item.CreatedAt, &item.UpdatedAt, &item.CompletedAt); err != nil {
return PrivacyRequest{}, err
}
if err := decodeObject(scopeJSON, &item.Scope); err != nil {
return PrivacyRequest{}, err
}
if err := decodeObject(resultJSON, &item.ResultSummary); err != nil {
return PrivacyRequest{}, err
}
item.Subtasks = []PrivacySubtask{}
return item, nil
}
func (s *Service) loadPrivacySubtasks(ctx context.Context, item *PrivacyRequest) error {
rows, err := s.pool.Query(ctx, `SELECT ps.public_id::text,ps.subsystem,ps.target_type,ps.target_ref,ps.status,ps.attempts,ps.next_attempt_at,COALESCE(ps.last_error,''),ps.completed_at FROM privacy_subtask ps JOIN privacy_request pr ON pr.id=ps.privacy_request_id WHERE pr.public_id=$1::uuid ORDER BY ps.created_at,ps.id`, item.ID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var subtask PrivacySubtask
if err := rows.Scan(&subtask.ID, &subtask.Subsystem, &subtask.TargetType, &subtask.TargetRef, &subtask.Status, &subtask.Attempts, &subtask.NextAttemptAt, &subtask.LastError, &subtask.CompletedAt); err != nil {
return err
}
item.Subtasks = append(item.Subtasks, subtask)
}
return rows.Err()
}
+183
View File
@@ -0,0 +1,183 @@
package saas
import (
"context"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/firmwareversion"
"data-terminal-cloud/internal/identity"
)
const deviceRecentWindow = 24 * time.Hour
type ConversationReadinessCheck struct {
Code string `json:"code"`
Status string `json:"status"`
Blocking bool `json:"blocking"`
Title string `json:"title"`
Detail string `json:"detail"`
Action string `json:"action,omitempty"`
}
type ConversationReadinessDevice struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
FirmwareVersion *string `json:"firmware_version,omitempty"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"`
DesiredRevision int64 `json:"desired_revision"`
ReportedRevision *int64 `json:"reported_revision,omitempty"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
}
type ConversationReadiness struct {
SubjectID string `json:"subject_id"`
Ready bool `json:"ready"`
EvaluatedAt time.Time `json:"evaluated_at"`
Checks []ConversationReadinessCheck `json:"checks"`
Devices []ConversationReadinessDevice `json:"devices"`
StartTimeChecks []string `json:"start_time_checks"`
}
// GetConversationReadiness evaluates the household-manageable prerequisites for
// a subject's first call. Quota, routing and concurrent-call checks deliberately
// remain start-time checks because their state can change immediately after this
// response is returned.
func (s *Service) GetConversationReadiness(ctx context.Context, principal identity.Principal, subjectPublicID string) (ConversationReadiness, error) {
access, subject, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return ConversationReadiness{}, err
}
result := ConversationReadiness{
SubjectID: subjectPublicID,
Ready: true,
EvaluatedAt: time.Now().UTC(),
Checks: make([]ConversationReadinessCheck, 0, 8),
Devices: make([]ConversationReadinessDevice, 0),
StartTimeChecks: []string{"usage_quota", "runtime_routing", "no_active_conversation"},
}
add := func(check ConversationReadinessCheck) {
result.Checks = append(result.Checks, check)
if check.Blocking && check.Status != "ready" {
result.Ready = false
}
}
if subject.Status == "active" {
add(readinessCheck("SUBJECT_ACTIVE", "ready", true, "长者档案已启用", "当前档案允许创建新通话。", ""))
} else {
add(readinessCheck("SUBJECT_ACTIVE", "blocked", true, "长者档案未启用", "请先把长者档案状态恢复为启用。", "EDIT_PROFILE"))
}
if nonEmptyProfileString(subject.ProfileData, "background") {
add(readinessCheck("PROFILE_BACKGROUND", "ready", true, "长者背景已填写", "授权背景会按档案版本冻结到新通话。", ""))
} else {
add(readinessCheck("PROFILE_BACKGROUND", "blocked", true, "缺少长者背景", "请填写简要家庭背景,避免首次交流缺少上下文。", "EDIT_PROFILE"))
}
if nonEmptyProfileString(subject.ConversationPreferences, "conversation_goal") {
add(readinessCheck("CONVERSATION_GOAL", "ready", true, "交流目标已填写", "新通话将使用当前档案版本中的交流目标。", ""))
} else {
add(readinessCheck("CONVERSATION_GOAL", "blocked", true, "缺少交流目标", "请填写本阶段希望达成的交流目标。", "EDIT_PROFILE"))
}
var hasVoiceConsent bool
err = s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM consent_snapshot
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid
AND effective_consents @> '{"voice_chat":true}'::jsonb
)`, access.tenantID, access.subjectID).Scan(&hasVoiceConsent)
if err != nil {
return ConversationReadiness{}, fmt.Errorf("check voice consent readiness: %w", err)
}
if hasVoiceConsent {
add(readinessCheck("VOICE_CONSENT", "ready", true, "语音通话授权已具备", "服务端已存在可冻结到新通话的有效授权快照。", ""))
} else {
add(readinessCheck("VOICE_CONSENT", "blocked", true, "缺少语音通话授权", "请完成至少一项明确分项授权并保留授权凭证。", "MANAGE_CONSENTS"))
}
rows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,d.model,d.status,dt.firmware_version,d.minimum_firmware_version,
dt.desired_revision,dt.reported_revision,dt.last_reported_at
FROM device_binding b
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
WHERE b.tenant_id=$1::uuid AND b.subject_id=$2::uuid AND b.unbound_at IS NULL
ORDER BY b.bound_at DESC,d.id`, access.tenantID, access.subjectID)
if err != nil {
return ConversationReadiness{}, fmt.Errorf("list readiness devices: %w", err)
}
defer rows.Close()
for rows.Next() {
var item ConversationReadinessDevice
if err := rows.Scan(&item.ID, &item.Model, &item.Status, &item.FirmwareVersion, &item.MinimumFirmwareVersion,
&item.DesiredRevision, &item.ReportedRevision, &item.LastReportedAt); err != nil {
return ConversationReadiness{}, fmt.Errorf("scan readiness device: %w", err)
}
result.Devices = append(result.Devices, item)
}
if err := rows.Err(); err != nil {
return ConversationReadiness{}, fmt.Errorf("iterate readiness devices: %w", err)
}
if len(result.Devices) == 0 {
add(readinessCheck("DEVICE_BOUND", "blocked", true, "尚未绑定设备", "请使用设备屏幕显示的一次性绑定码完成绑定。", "BIND_DEVICE"))
add(readinessCheck("DEVICE_RUNTIME", "blocked", true, "没有可用于通话的设备", "绑定并启动设备后才能发起首次通话。", "BIND_DEVICE"))
return result, nil
}
add(readinessCheck("DEVICE_BOUND", "ready", true, "设备已绑定", fmt.Sprintf("当前长者已绑定%d台设备。", len(result.Devices)), ""))
registered, reported, compatible, synced, recent := 0, 0, 0, 0, 0
for _, device := range result.Devices {
if device.Status != "registered" {
continue
}
registered++
if device.FirmwareVersion != nil && strings.TrimSpace(*device.FirmwareVersion) != "" {
reported++
if device.MinimumFirmwareVersion == nil || firmwareversion.AtLeast(*device.FirmwareVersion, *device.MinimumFirmwareVersion) {
compatible++
}
}
if device.ReportedRevision != nil && *device.ReportedRevision == device.DesiredRevision {
synced++
}
if device.LastReportedAt != nil && result.EvaluatedAt.Sub(*device.LastReportedAt) <= deviceRecentWindow {
recent++
}
}
if registered > 0 && reported > 0 {
add(readinessCheck("DEVICE_RUNTIME", "ready", true, "设备运行信息完整", fmt.Sprintf("%d台已注册设备已报告固件版本。", reported), ""))
} else if registered == 0 {
add(readinessCheck("DEVICE_RUNTIME", "blocked", true, "绑定设备不可用", "已绑定设备均不是注册状态,请联系运维处理设备状态。", "OPEN_DEVICES"))
} else {
add(readinessCheck("DEVICE_RUNTIME", "blocked", true, "设备尚未完成启动上报", "请启动设备并等待其完成引导和固件信息上报。", "OPEN_DEVICES"))
}
if compatible > 0 {
add(readinessCheck("FIRMWARE_POLICY", "ready", true, "设备固件满足最低版本", fmt.Sprintf("%d台设备可通过新通话固件门禁。", compatible), ""))
} else {
add(readinessCheck("FIRMWARE_POLICY", "blocked", true, "设备固件需要升级", "当前已上报设备均未达到最低固件版本,请先完成签名固件升级。", "OPEN_DEVICES"))
}
if synced > 0 {
add(readinessCheck("CONFIG_SYNC", "ready", false, "设备配置已同步", fmt.Sprintf("%d台设备已应用当前配置版本。", synced), ""))
} else {
add(readinessCheck("CONFIG_SYNC", "attention", false, "设备配置待同步", "这不会阻止创建通话,但设备应尽快拉取并应用当前配置。", "OPEN_DEVICES"))
}
if recent > 0 {
add(readinessCheck("RECENT_CONTACT", "ready", false, "设备近期已联系云端", fmt.Sprintf("%d台设备在24小时内完成过状态上报。", recent), ""))
} else {
add(readinessCheck("RECENT_CONTACT", "attention", false, "设备近期未联系云端", "通话开始前请确认设备已联网;在线状态会在通话心跳后更新。", "OPEN_DEVICES"))
}
return result, nil
}
func readinessCheck(code, status string, blocking bool, title, detail, action string) ConversationReadinessCheck {
return ConversationReadinessCheck{Code: code, Status: status, Blocking: blocking, Title: title, Detail: detail, Action: action}
}
func nonEmptyProfileString(values map[string]any, key string) bool {
value, ok := values[key].(string)
return ok && strings.TrimSpace(value) != ""
}
@@ -0,0 +1,44 @@
package saas
import (
"context"
"os"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestConversationReadinessReflectsSeedFacts(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service := NewService(pool, audit.NewRepository(), nil, nil)
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
result, err := service.GetConversationReadiness(context.Background(), principal, "10000000-0000-0000-0000-000000000411")
if err != nil {
t.Fatal(err)
}
checks := make(map[string]ConversationReadinessCheck, len(result.Checks))
for _, check := range result.Checks {
checks[check.Code] = check
}
if result.Ready || checks["PROFILE_BACKGROUND"].Status != "blocked" || checks["CONVERSATION_GOAL"].Status != "blocked" {
t.Fatalf("expected incomplete seed profile to block readiness: %#v", result)
}
if checks["VOICE_CONSENT"].Status != "ready" || checks["DEVICE_BOUND"].Status != "ready" || checks["DEVICE_RUNTIME"].Status != "ready" || checks["FIRMWARE_POLICY"].Status != "ready" {
t.Fatalf("expected seeded consent and device to be ready: %#v", checks)
}
if len(result.Devices) != 1 || result.Devices[0].ID != "10000000-0000-0000-0000-000000000511" {
t.Fatalf("unexpected readiness devices: %#v", result.Devices)
}
}
+803
View File
@@ -0,0 +1,803 @@
package saas
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
var (
ErrAccessDenied = errors.New("resource access denied")
ErrNotFound = errors.New("resource not found")
ErrVersionConflict = errors.New("resource version conflict")
ErrEffectiveConsentExist = errors.New("an effective consent already exists")
ErrPolicyVersionMismatch = errors.New("consent policy version mismatch")
ErrDeviceClaimInvalid = errors.New("device claim is invalid")
ErrDeviceClaimUsed = errors.New("device claim was used for another binding")
ErrDeviceAlreadyBound = errors.New("device already has an active binding")
ErrSubjectDeleteConflict = errors.New("subject cannot be deleted while a conversation is active")
ErrDeleteConfirmation = errors.New("subject deletion confirmation does not match")
)
const (
ConsentTranscriptStorage = "transcript_storage"
ConsentRawAudioStorage = "raw_audio_storage"
ConsentStoryExtraction = "story_extraction"
ConsentPersonalMemory = "personalized_memory"
ConsentFamilySharing = "family_sharing"
)
var consentTypes = []string{
ConsentTranscriptStorage,
ConsentRawAudioStorage,
ConsentStoryExtraction,
ConsentPersonalMemory,
ConsentFamilySharing,
}
type Household struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Status string `json:"status"`
Role identity.Role `json:"role"`
Capabilities []identity.Capability `json:"capabilities"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Subject struct {
ID string `json:"id"`
HouseholdID string `json:"household_id"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
Status string `json:"status"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Consent struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Type string `json:"type"`
Scope map[string]any `json:"scope"`
PolicyVersion string `json:"policy_version"`
NoticeDigest string `json:"notice_digest"`
ActorUserID string `json:"actor_user_id"`
EvidenceRef string `json:"evidence_ref,omitempty"`
GrantedAt time.Time `json:"granted_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
WithdrawnAt *time.Time `json:"withdrawn_at,omitempty"`
Effective bool `json:"effective"`
Version int64 `json:"version"`
}
type CreateSubjectInput struct {
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
}
type PatchSubjectInput struct {
PreferredName *string `json:"preferred_name,omitempty"`
Language *string `json:"language,omitempty"`
Timezone *string `json:"timezone,omitempty"`
ProfileData *map[string]any `json:"profile_data,omitempty"`
ConversationPreferences *map[string]any `json:"conversation_preferences,omitempty"`
Status *string `json:"status,omitempty"`
}
type GrantConsentInput struct {
Type string `json:"type"`
Scope map[string]any `json:"scope"`
PolicyVersion string `json:"policy_version"`
NoticeDigest string `json:"notice_digest"`
EvidenceRef string `json:"evidence_ref,omitempty"`
GrantedAt time.Time `json:"granted_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type Service struct {
pool *pgxpool.Pool
audit *audit.Repository
redis *redis.Client
cursor *pagination.Codec
exportSigner exportURLSigner
exportDownloadTTL time.Duration
firmwareStore firmwareObjectStore
firmwareURLTTL time.Duration
}
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, redisClient *redis.Client, cursorCodec *pagination.Codec) *Service {
return &Service{pool: pool, audit: auditRepository, redis: redisClient, cursor: cursorCodec}
}
func IsConsentType(value string) bool {
for _, consentType := range consentTypes {
if value == consentType {
return true
}
}
return false
}
func (s *Service) ListHouseholds(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Household, error) {
rows, err := s.pool.Query(ctx, `
SELECT h.public_id::text, t.public_id::text, h.name, h.status, hm.role,
h.version, h.created_at, h.updated_at
FROM user_account ua
JOIN tenant t ON t.id=ua.tenant_id AND t.status='active'
JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active'
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id
WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active'
AND h.status <> 'deleted'
AND (NULLIF($3,'') IS NULL OR t.public_id=NULLIF($3,'')::uuid)
ORDER BY h.updated_at DESC, h.public_id`, principal.Issuer, principal.Subject, tenantPublicID)
if err != nil {
return nil, fmt.Errorf("list households: %w", err)
}
defer rows.Close()
result := make([]Household, 0)
for rows.Next() {
var item Household
if err := rows.Scan(&item.ID, &item.TenantID, &item.Name, &item.Status, &item.Role,
&item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan household: %w", err)
}
item.Capabilities = identity.CapabilitiesForRole(item.Role)
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate households: %w", err)
}
return result, nil
}
func (s *Service) CreateHousehold(ctx context.Context, principal identity.Principal, tenantPublicID, name, requestID string) (Household, error) {
var result Household
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var tenantID, userID, userPublicID string
if err := tx.QueryRow(ctx, `
SELECT t.id::text, ua.id::text, ua.public_id::text
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
WHERE t.public_id=$1::uuid AND t.status='active'
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
FOR SHARE OF t, ua`, tenantPublicID, principal.Issuer, principal.Subject,
).Scan(&tenantID, &userID, &userPublicID); errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
} else if err != nil {
return fmt.Errorf("resolve tenant account: %w", err)
}
if err := tx.QueryRow(ctx, `
INSERT INTO household (tenant_id, name, owner_user_id)
VALUES ($1::uuid, $2, $3::uuid)
RETURNING public_id::text, $4::text, name, status, version, created_at, updated_at`,
tenantID, name, userID, tenantPublicID,
).Scan(&result.ID, &result.TenantID, &result.Name, &result.Status,
&result.Version, &result.CreatedAt, &result.UpdatedAt); err != nil {
return fmt.Errorf("insert household: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO household_member (tenant_id, household_id, user_id, role)
SELECT $1::uuid, id, $2::uuid, 'owner' FROM household WHERE public_id=$3::uuid`,
tenantID, userID, result.ID); err != nil {
return fmt.Errorf("insert household owner membership: %w", err)
}
result.Role = identity.RoleOwner
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID),
Action: "household.create", ResourceType: "household", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"version": result.Version},
})
})
return result, err
}
func (s *Service) GetHousehold(ctx context.Context, principal identity.Principal, householdPublicID string) (Household, error) {
var result Household
if err := s.pool.QueryRow(ctx, householdSelectSQL+` AND h.public_id=$3::uuid`,
principal.Issuer, principal.Subject, householdPublicID,
).Scan(&result.ID, &result.TenantID, &result.Name, &result.Status, &result.Role,
&result.Version, &result.CreatedAt, &result.UpdatedAt); errors.Is(err, pgx.ErrNoRows) {
return Household{}, ErrAccessDenied
} else if err != nil {
return Household{}, fmt.Errorf("get household: %w", err)
}
if !identity.Can(result.Role, identity.CapabilityHouseholdRead) {
return Household{}, ErrAccessDenied
}
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return result, nil
}
func (s *Service) PatchHousehold(ctx context.Context, principal identity.Principal, householdPublicID, name string, expectedVersion int64, requestID string) (Household, error) {
var result Household
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityHouseholdManage, true)
if err != nil {
return err
}
if err := concurrency.RequireVersion(expectedVersion, access.version); err != nil {
return ErrVersionConflict
}
if err := tx.QueryRow(ctx, `
UPDATE household SET name=$1, version=version+1, updated_at=now()
WHERE id=$2::uuid AND tenant_id=$3::uuid
RETURNING public_id::text, $4::text, name, status, version, created_at, updated_at`,
name, access.householdID, access.tenantID, access.tenantPublicID,
).Scan(&result.ID, &result.TenantID, &result.Name, &result.Status,
&result.Version, &result.CreatedAt, &result.UpdatedAt); err != nil {
return fmt.Errorf("update household: %w", err)
}
result.Role = access.role
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "household.update", ResourceType: "household", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"from_version": expectedVersion, "to_version": result.Version},
})
})
return result, err
}
func (s *Service) ListSubjects(ctx context.Context, principal identity.Principal, householdPublicID string) ([]Subject, error) {
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT s.public_id::text, $1::text, s.preferred_name, s.language, s.timezone,
s.profile_data, s.conversation_preferences, s.status, s.profile_version, s.created_at, s.updated_at
FROM subject_profile s
WHERE s.tenant_id=$2::uuid AND s.household_id=$3::uuid AND s.status IN ('active','suspended')
ORDER BY s.updated_at DESC, s.public_id`, householdPublicID, access.tenantID, access.householdID)
if err != nil {
return nil, fmt.Errorf("list subjects: %w", err)
}
defer rows.Close()
result := make([]Subject, 0)
for rows.Next() {
item, err := scanSubject(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate subjects: %w", err)
}
return result, nil
}
func (s *Service) CreateSubject(ctx context.Context, principal identity.Principal, householdPublicID, requestID string, input CreateSubjectInput) (Subject, error) {
var result Subject
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
profileJSON, _ := json.Marshal(input.ProfileData)
preferencesJSON, _ := json.Marshal(input.ConversationPreferences)
row := tx.QueryRow(ctx, `
INSERT INTO subject_profile (
tenant_id, household_id, preferred_name, language, timezone,
profile_data, conversation_preferences, created_by, updated_by
) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7::jsonb, $8::uuid, $8::uuid)
RETURNING public_id::text, $9::text, preferred_name, language, timezone,
profile_data, conversation_preferences, status, profile_version, created_at, updated_at`,
access.tenantID, access.householdID, input.PreferredName, input.Language, input.Timezone,
profileJSON, preferencesJSON, access.userID, householdPublicID)
result, err = scanSubject(row)
if err != nil {
return fmt.Errorf("insert subject: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject.create", ResourceType: "subject", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "version": result.Version},
})
})
return result, err
}
func (s *Service) GetSubject(ctx context.Context, principal identity.Principal, subjectPublicID string) (Subject, error) {
access, result, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
_ = access
return result, err
}
func (s *Service) PatchSubject(ctx context.Context, principal identity.Principal, subjectPublicID, requestID string, expectedVersion int64, input PatchSubjectInput) (Subject, error) {
var result Subject
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
preferredName, language, timezone, status := current.PreferredName, current.Language, current.Timezone, current.Status
profile, preferences := current.ProfileData, current.ConversationPreferences
if input.PreferredName != nil {
preferredName = *input.PreferredName
}
if input.Language != nil {
language = *input.Language
}
if input.Timezone != nil {
timezone = *input.Timezone
}
if input.Status != nil {
status = *input.Status
}
if input.ProfileData != nil {
profile = *input.ProfileData
}
if input.ConversationPreferences != nil {
preferences = *input.ConversationPreferences
}
profileJSON, _ := json.Marshal(profile)
preferencesJSON, _ := json.Marshal(preferences)
row := tx.QueryRow(ctx, `
UPDATE subject_profile SET preferred_name=$1, language=$2, timezone=$3,
profile_data=$4::jsonb, conversation_preferences=$5::jsonb, status=$6,
updated_by=$7::uuid, profile_version=profile_version+1, updated_at=now()
WHERE id=$8::uuid AND tenant_id=$9::uuid
RETURNING public_id::text, $10::text, preferred_name, language, timezone,
profile_data, conversation_preferences, status, profile_version, created_at, updated_at`,
preferredName, language, timezone, profileJSON, preferencesJSON, status,
access.userID, access.subjectID, access.tenantID, access.householdPublicID)
result, err = scanSubject(row)
if err != nil {
return fmt.Errorf("update subject: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject.update", ResourceType: "subject", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"from_version": expectedVersion, "to_version": result.Version},
})
})
return result, err
}
func (s *Service) ListConsents(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]Consent, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT c.public_id::text, $1::text, c.consent_type, c.scope, c.policy_version,
c.notice_digest, ua.public_id::text, COALESCE(c.evidence_ref,''), c.granted_at,
c.expires_at, c.withdrawn_at, c.version
FROM consent_record c JOIN user_account ua ON ua.id=c.actor_user_id AND ua.tenant_id=c.tenant_id
WHERE c.tenant_id=$2::uuid AND c.subject_id=$3::uuid
ORDER BY c.granted_at DESC, c.id DESC`, subjectPublicID, access.tenantID, access.subjectID)
if err != nil {
return nil, fmt.Errorf("list consents: %w", err)
}
defer rows.Close()
result := make([]Consent, 0)
for rows.Next() {
item, err := scanConsent(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate consents: %w", err)
}
return result, nil
}
func (s *Service) GrantConsent(ctx context.Context, principal identity.Principal, subjectPublicID, requestID string, input GrantConsentInput) (Consent, error) {
var result Consent
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
if input.PolicyVersion != access.policyVersion {
return ErrPolicyVersionMismatch
}
var existing bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid
AND consent_type=$3 AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at > now())
)`, access.tenantID, access.subjectID, input.Type).Scan(&existing); err != nil {
return fmt.Errorf("check effective consent: %w", err)
}
if existing {
return ErrEffectiveConsentExist
}
scopeJSON, _ := json.Marshal(input.Scope)
row := tx.QueryRow(ctx, `
INSERT INTO consent_record (
tenant_id, subject_id, consent_type, scope, policy_version, notice_digest,
actor_user_id, evidence_ref, granted_at, expires_at
) VALUES ($1::uuid,$2::uuid,$3,$4::jsonb,$5,$6,$7::uuid,NULLIF($8,''),$9,$10)
RETURNING public_id::text, $11::text, consent_type, scope, policy_version,
notice_digest, $12::text, COALESCE(evidence_ref,''), granted_at, expires_at, withdrawn_at, version`,
access.tenantID, access.subjectID, input.Type, scopeJSON, input.PolicyVersion,
input.NoticeDigest, access.userID, input.EvidenceRef, input.GrantedAt, input.ExpiresAt,
subjectPublicID, access.userPublicID)
result, err = scanConsent(row)
if err != nil {
return fmt.Errorf("insert consent: %w", err)
}
if err := createConsentSnapshot(ctx, tx, access.tenantID, access.subjectID); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "consent.grant", ResourceType: "consent", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "consent_type": result.Type},
})
})
return result, err
}
func (s *Service) WithdrawConsent(ctx context.Context, principal identity.Principal, subjectPublicID, consentPublicID, requestID string) (Consent, error) {
var result Consent
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
row := tx.QueryRow(ctx, `
SELECT c.public_id::text, $1::text, c.consent_type, c.scope, c.policy_version,
c.notice_digest, ua.public_id::text, COALESCE(c.evidence_ref,''), c.granted_at,
c.expires_at, c.withdrawn_at, c.version
FROM consent_record c JOIN user_account ua ON ua.id=c.actor_user_id AND ua.tenant_id=c.tenant_id
WHERE c.public_id=$2::uuid AND c.tenant_id=$3::uuid AND c.subject_id=$4::uuid
FOR UPDATE OF c`, subjectPublicID, consentPublicID, access.tenantID, access.subjectID)
result, err = scanConsent(row)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("get consent for withdrawal: %w", err)
}
if result.WithdrawnAt != nil {
return nil
}
row = tx.QueryRow(ctx, `
UPDATE consent_record SET withdrawn_at=now(), version=version+1, updated_at=now()
WHERE public_id=$1::uuid
RETURNING public_id::text, $2::text, consent_type, scope, policy_version,
notice_digest, $3::text, COALESCE(evidence_ref,''), granted_at, expires_at, withdrawn_at, version`,
consentPublicID, subjectPublicID, result.ActorUserID)
result, err = scanConsent(row)
if err != nil {
return fmt.Errorf("withdraw consent: %w", err)
}
if err := createConsentSnapshot(ctx, tx, access.tenantID, access.subjectID); err != nil {
return err
}
if result.Type == "personalized_memory" {
if _, err := tx.Exec(ctx, `
UPDATE curated_memory SET status='DISABLED',version=version+1,updated_at=now()
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status='ACTIVE'`, access.tenantID, access.subjectID); err != nil {
return fmt.Errorf("disable memories after consent withdrawal: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='personalized memory consent withdrawn',updated_at=now()
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status IN ('PENDING','ACTIVE')`, access.tenantID, access.subjectID); err != nil {
return fmt.Errorf("invalidate memory index after consent withdrawal: %w", err)
}
}
affectedConversations, err := s.stopConversationsAfterConsentWithdrawal(ctx, tx, access.tenantID, access.subjectID, result.Type)
if err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "consent.withdraw", ResourceType: "consent", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{
"subject_id": subjectPublicID, "consent_type": result.Type,
"stopped_conversation_ids": affectedConversations,
},
})
})
return result, err
}
func consentWithdrawalStopsConversation(consentType string) bool {
switch consentType {
case ConsentTranscriptStorage, ConsentRawAudioStorage, ConsentStoryExtraction, ConsentPersonalMemory:
return true
default:
return false
}
}
func (s *Service) stopConversationsAfterConsentWithdrawal(
ctx context.Context,
tx pgx.Tx,
tenantID, subjectID, consentType string,
) ([]string, error) {
if !consentWithdrawalStopsConversation(consentType) {
return []string{}, nil
}
type candidate struct {
internalID, publicID, status string
}
rows, err := tx.Query(ctx, `
SELECT c.id::text,c.public_id::text,c.status
FROM conversation c
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
JOIN rtc_task rt ON rt.conversation_id=c.id
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
AND c.status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE')
AND cs.effective_consents @> jsonb_build_object($3::text,true)
ORDER BY c.created_at,c.id
FOR UPDATE OF c,rt`, tenantID, subjectID, consentType)
if err != nil {
return nil, fmt.Errorf("find conversations affected by consent withdrawal: %w", err)
}
candidates := make([]candidate, 0)
for rows.Next() {
var item candidate
if err := rows.Scan(&item.internalID, &item.publicID, &item.status); err != nil {
rows.Close()
return nil, fmt.Errorf("scan consent withdrawal conversation: %w", err)
}
candidates = append(candidates, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate consent withdrawal conversations: %w", err)
}
rows.Close()
affected := make([]string, 0, len(candidates))
stopReason := "consent_withdrawn:" + consentType
for _, item := range candidates {
toStatus := "EXPIRED"
if item.status == "STARTING_AGENT" || item.status == "ACTIVE" {
toStatus = "STOPPING"
}
tag, err := tx.Exec(ctx, `
UPDATE conversation SET status=$2::varchar,stop_reason=$3,ended_at=CASE WHEN $2::text='EXPIRED' THEN now() ELSE ended_at END,
lease_expires_at=NULL,version=version+1,updated_at=now()
WHERE id=$1::uuid AND status=$4::varchar`, item.internalID, toStatus, stopReason, item.status)
if err != nil {
return nil, fmt.Errorf("stop conversation after consent withdrawal: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("conversation changed during consent withdrawal")
}
if toStatus == "EXPIRED" {
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
} else {
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
}
if err != nil {
return nil, fmt.Errorf("update RTC after consent withdrawal: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source)
VALUES($1::uuid,$2,$3,$4,'platform')`, item.internalID, item.status, toStatus, "consent withdrawn: "+consentType); err != nil {
return nil, fmt.Errorf("record consent withdrawal stop: %w", err)
}
if toStatus == "STOPPING" {
deduplicationKey := "consent-withdrawal-stop:" + item.internalID + ":" + consentType
payload, _ := json.Marshal(map[string]string{
"conversation_id": item.publicID,
"reason": stopReason,
})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{
TenantID: strPtr(tenantID), AggregateType: "conversation", AggregateID: item.internalID,
EventType: "conversation.agent_stop_requested", DeduplicationKey: &deduplicationKey, Payload: payload,
}); err != nil {
return nil, fmt.Errorf("enqueue consent withdrawal stop: %w", err)
}
}
affected = append(affected, item.publicID)
}
return affected, nil
}
const householdSelectSQL = `
SELECT h.public_id::text, t.public_id::text, h.name, h.status, hm.role,
h.version, h.created_at, h.updated_at
FROM household h
JOIN tenant t ON t.id=h.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND h.status <> 'deleted'`
type queryRow interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
type resourceAccess struct {
tenantID, tenantPublicID, userID, userPublicID string
householdID, householdPublicID, subjectID string
policyVersion string
role identity.Role
version int64
}
func resolveHouseholdAccess(ctx context.Context, q queryRow, principal identity.Principal, publicID string, capability identity.Capability, lock bool) (resourceAccess, error) {
sql := `
SELECT t.id::text,t.public_id::text,t.policy_version,ua.id::text,ua.public_id::text,
h.id::text,h.public_id::text,hm.role,h.version
FROM household h
JOIN tenant t ON t.id=h.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE h.public_id=$1::uuid AND h.status <> 'deleted'
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF h`
}
var a resourceAccess
var role string
err := q.QueryRow(ctx, sql, publicID, principal.Issuer, principal.Subject).Scan(
&a.tenantID, &a.tenantPublicID, &a.policyVersion, &a.userID, &a.userPublicID,
&a.householdID, &a.householdPublicID, &role, &a.version)
if errors.Is(err, pgx.ErrNoRows) {
return resourceAccess{}, ErrAccessDenied
}
if err != nil {
return resourceAccess{}, fmt.Errorf("resolve household access: %w", err)
}
a.role = identity.Role(role)
if !identity.Can(a.role, capability) {
return resourceAccess{}, ErrAccessDenied
}
return a, nil
}
func resolveSubject(ctx context.Context, q queryRow, principal identity.Principal, publicID string, capability identity.Capability, lock bool) (resourceAccess, Subject, error) {
sql := `
SELECT t.id::text,t.public_id::text,t.policy_version,ua.id::text,ua.public_id::text,
h.id::text,h.public_id::text,s.id::text,hm.role,
s.public_id::text,h.public_id::text,s.preferred_name,s.language,s.timezone,
s.profile_data,s.conversation_preferences,s.status,s.profile_version,s.created_at,s.updated_at
FROM subject_profile s
JOIN household h ON h.id=s.household_id AND h.tenant_id=s.tenant_id AND h.status <> 'deleted'
JOIN tenant t ON t.id=s.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE s.public_id=$1::uuid AND s.status IN ('active','suspended')
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF s`
}
var a resourceAccess
var role string
var result Subject
var profileJSON, preferencesJSON []byte
err := q.QueryRow(ctx, sql, publicID, principal.Issuer, principal.Subject).Scan(
&a.tenantID, &a.tenantPublicID, &a.policyVersion, &a.userID, &a.userPublicID,
&a.householdID, &a.householdPublicID, &a.subjectID, &role,
&result.ID, &result.HouseholdID, &result.PreferredName, &result.Language, &result.Timezone,
&profileJSON, &preferencesJSON, &result.Status, &result.Version, &result.CreatedAt, &result.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return resourceAccess{}, Subject{}, ErrAccessDenied
}
if err != nil {
return resourceAccess{}, Subject{}, fmt.Errorf("resolve subject: %w", err)
}
a.role = identity.Role(role)
if !identity.Can(a.role, capability) {
return resourceAccess{}, Subject{}, ErrAccessDenied
}
if err := decodeObject(profileJSON, &result.ProfileData); err != nil {
return resourceAccess{}, Subject{}, err
}
if err := decodeObject(preferencesJSON, &result.ConversationPreferences); err != nil {
return resourceAccess{}, Subject{}, err
}
return a, result, nil
}
type rowScanner interface{ Scan(...any) error }
func scanSubject(row rowScanner) (Subject, error) {
var item Subject
var profileJSON, preferencesJSON []byte
if err := row.Scan(&item.ID, &item.HouseholdID, &item.PreferredName, &item.Language, &item.Timezone,
&profileJSON, &preferencesJSON, &item.Status, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return Subject{}, err
}
if err := decodeObject(profileJSON, &item.ProfileData); err != nil {
return Subject{}, err
}
if err := decodeObject(preferencesJSON, &item.ConversationPreferences); err != nil {
return Subject{}, err
}
return item, nil
}
func scanConsent(row rowScanner) (Consent, error) {
var item Consent
var scopeJSON []byte
if err := row.Scan(&item.ID, &item.SubjectID, &item.Type, &scopeJSON, &item.PolicyVersion,
&item.NoticeDigest, &item.ActorUserID, &item.EvidenceRef, &item.GrantedAt,
&item.ExpiresAt, &item.WithdrawnAt, &item.Version); err != nil {
return Consent{}, err
}
if err := decodeObject(scopeJSON, &item.Scope); err != nil {
return Consent{}, err
}
item.Effective = item.WithdrawnAt == nil && (item.ExpiresAt == nil || item.ExpiresAt.After(time.Now()))
return item, nil
}
func createConsentSnapshot(ctx context.Context, tx pgx.Tx, tenantID, subjectID string) error {
effective := map[string]bool{"voice_chat": true}
for _, consentType := range consentTypes {
effective[consentType] = false
}
rows, err := tx.Query(ctx, `
SELECT public_id::text, consent_type FROM consent_record
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND withdrawn_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
ORDER BY granted_at,id`, tenantID, subjectID)
if err != nil {
return fmt.Errorf("query effective consents: %w", err)
}
defer rows.Close()
sourceIDs := make([]string, 0)
for rows.Next() {
var publicID, consentType string
if err := rows.Scan(&publicID, &consentType); err != nil {
return fmt.Errorf("scan effective consent: %w", err)
}
effective[consentType] = true
sourceIDs = append(sourceIDs, publicID)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate effective consents: %w", err)
}
effectiveJSON, _ := json.Marshal(effective)
sourceJSON, _ := json.Marshal(sourceIDs)
if _, err := tx.Exec(ctx, `
INSERT INTO consent_snapshot (tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
SELECT $1::uuid,$2::uuid,COALESCE(MAX(snapshot_version),0)+1,$3::jsonb,$4::jsonb
FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
tenantID, subjectID, effectiveJSON, sourceJSON); err != nil {
return fmt.Errorf("insert consent snapshot: %w", err)
}
return nil
}
func decodeObject(value []byte, target *map[string]any) error {
if len(value) == 0 {
*target = map[string]any{}
return nil
}
if err := json.Unmarshal(value, target); err != nil {
return fmt.Errorf("decode JSON object: %w", err)
}
if *target == nil {
*target = map[string]any{}
}
return nil
}
func strPtr(value string) *string { return &value }
+778
View File
@@ -0,0 +1,778 @@
package saas
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
testTenantPublicID = "10000000-0000-0000-0000-000000000010"
testIssuer = "http://localhost:58180/realms/voice-local"
testOwnerSubject = "00000000-0000-0000-0000-000000000101"
testEditorSubject = "00000000-0000-0000-0000-000000000102"
testOutsideSubject = "00000000-0000-0000-0000-000000000103"
)
func TestHouseholdSubjectConsentLifecycleAndIsolation(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 := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
household, err := service.CreateHousehold(ctx, owner, testTenantPublicID, "集成测试家庭", "saas-integration-create")
if err != nil {
t.Fatalf("create household: %v", err)
}
var subjectID string
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'saas-integration-%'`)
if subjectID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM privacy_request WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid) UNION SELECT id FROM export_task WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid))`, subjectID)
_, _ = pool.Exec(ctx, `DELETE FROM privacy_request WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(ctx, `DELETE FROM export_task WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(ctx, `DELETE FROM consent_record WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(ctx, `DELETE FROM subject_fact WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(ctx, `DELETE FROM subject_profile WHERE public_id=$1::uuid`, subjectID)
}
_, _ = pool.Exec(ctx, `DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, household.ID)
_, _ = pool.Exec(ctx, `DELETE FROM household WHERE public_id=$1::uuid`, household.ID)
}()
if household.Version != 1 || household.Role != identity.RoleOwner {
t.Fatalf("unexpected household: %+v", household)
}
items, err := service.ListHouseholds(ctx, owner, testTenantPublicID)
if err != nil || !containsHousehold(items, household.ID) {
t.Fatalf("list household: items=%+v err=%v", items, err)
}
if _, err := service.GetHousehold(ctx, outsider, household.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider get error=%v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO household_member (tenant_id,household_id,user_id,role,status)
SELECT h.tenant_id,h.id,ua.id,'editor','active'
FROM household h JOIN user_account ua ON ua.tenant_id=h.tenant_id
WHERE h.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, household.ID, testIssuer, testEditorSubject); err != nil {
t.Fatalf("add editor: %v", err)
}
if _, err := service.PatchHousehold(ctx, editor, household.ID, "禁止修改", 1, "saas-integration-editor-denied"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("editor manage error=%v", err)
}
household, err = service.PatchHousehold(ctx, owner, household.ID, "集成测试家庭-已修改", 1, "saas-integration-household-patch")
if err != nil || household.Version != 2 {
t.Fatalf("patch household: %+v err=%v", household, err)
}
if _, err := service.PatchHousehold(ctx, owner, household.ID, "过期写入", 1, "saas-integration-stale"); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale household patch error=%v", err)
}
subject, err := service.CreateSubject(ctx, editor, household.ID, "saas-integration-subject-create", CreateSubjectInput{
PreferredName: "集成长者", Language: "zh-CN", Timezone: "Asia/Shanghai",
ProfileData: map[string]any{"synthetic": true}, ConversationPreferences: map[string]any{"pace": "slow"},
})
if err != nil {
t.Fatalf("create subject: %v", err)
}
subjectID = subject.ID
subjects, err := service.ListSubjects(ctx, editor, household.ID)
if err != nil || len(subjects) != 1 || subjects[0].ID != subject.ID {
t.Fatalf("list subjects: %+v err=%v", subjects, err)
}
if _, err := service.GetSubject(ctx, outsider, subject.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider subject error=%v", err)
}
newName := "集成长者-已修改"
subject, err = service.PatchSubject(ctx, editor, subject.ID, "saas-integration-subject-patch", 1, PatchSubjectInput{PreferredName: &newName})
if err != nil || subject.Version != 2 || subject.PreferredName != newName {
t.Fatalf("patch subject: %+v err=%v", subject, err)
}
fact, err := service.CreateSubjectFact(ctx, editor, subject.ID, "saas-integration-fact-create", CreateFactInput{
FactType: "hometown", Content: "合成城市", SourceType: "model_candidate", SourceRef: "synthetic://turn/1",
ConfirmationStatus: "pending", Sensitivity: "normal",
})
if err != nil || fact.Version != 1 {
t.Fatalf("create fact: %+v err=%v", fact, err)
}
confirmed := "confirmed"
fact, err = service.PatchSubjectFact(ctx, editor, subject.ID, fact.ID, "saas-integration-fact-confirm", 1, PatchFactInput{ConfirmationStatus: &confirmed})
if err != nil || fact.Version != 2 || fact.ConfirmationStatus != "confirmed" {
t.Fatalf("confirm fact: %+v err=%v", fact, err)
}
if _, err := service.PatchSubjectFact(ctx, editor, subject.ID, fact.ID, "saas-integration-fact-stale", 1, PatchFactInput{ConfirmationStatus: &confirmed}); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale fact patch error=%v", err)
}
facts, err := service.ListSubjectFacts(ctx, editor, subject.ID)
if err != nil || len(facts) != 1 {
t.Fatalf("list facts: %+v err=%v", facts, err)
}
grantInput := GrantConsentInput{
Type: ConsentStoryExtraction, Scope: map[string]any{"scope": "future_conversations"},
PolicyVersion: "local-v1", NoticeDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
EvidenceRef: "synthetic://integration", GrantedAt: time.Now().UTC(),
}
consent, err := service.GrantConsent(ctx, editor, subject.ID, "saas-integration-consent-grant", grantInput)
if err != nil || !consent.Effective {
t.Fatalf("grant consent: %+v err=%v", consent, err)
}
if _, err := service.GrantConsent(ctx, editor, subject.ID, "saas-integration-consent-duplicate", grantInput); !errors.Is(err, ErrEffectiveConsentExist) {
t.Fatalf("duplicate grant error=%v", err)
}
consent, err = service.WithdrawConsent(ctx, editor, subject.ID, consent.ID, "saas-integration-consent-withdraw")
if err != nil || consent.WithdrawnAt == nil || consent.Effective {
t.Fatalf("withdraw consent: %+v err=%v", consent, err)
}
replayed, err := service.WithdrawConsent(ctx, editor, subject.ID, consent.ID, "saas-integration-consent-withdraw-replay")
if err != nil || replayed.Version != consent.Version {
t.Fatalf("replay withdrawal: %+v err=%v", replayed, err)
}
consents, err := service.ListConsents(ctx, editor, subject.ID)
if err != nil || len(consents) != 1 {
t.Fatalf("list consents: %+v err=%v", consents, err)
}
privacyInput := CreatePrivacyRequestInput{RequestType: " access ", Scope: map[string]any{"categories": []any{"profile", "consents"}}}
privacyRequest, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-create", privacyInput)
if err != nil || !created || privacyRequest.Status != "SUBMITTED" || len(privacyRequest.Subtasks) != 1 {
t.Fatalf("privacy request=%+v created=%v err=%v", privacyRequest, created, err)
}
replayedPrivacy, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-replay", privacyInput)
if err != nil || created || replayedPrivacy.ID != privacyRequest.ID {
t.Fatalf("privacy replay=%+v created=%v err=%v", replayedPrivacy, created, err)
}
conflictingPrivacy := privacyInput
conflictingPrivacy.Scope = map[string]any{"categories": []any{"profile"}}
if _, _, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-conflict", conflictingPrivacy); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("privacy idempotency conflict=%v", err)
}
if _, err := service.ListPrivacyRequests(ctx, editor, subject.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("editor privacy list error=%v", err)
}
privacyRequests, err := service.ListPrivacyRequests(ctx, owner, subject.ID)
if err != nil || len(privacyRequests) != 1 || privacyRequests[0].ID != privacyRequest.ID {
t.Fatalf("privacy requests=%+v err=%v", privacyRequests, err)
}
if _, err := service.GetPrivacyRequest(ctx, outsider, privacyRequest.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider privacy request error=%v", err)
}
exportScope := map[string]any{"categories": []any{"profile", "consents"}}
exportTask, created, err := service.CreateExportTask(ctx, owner, subject.ID, "saas-integration-export-key", "saas-integration-export-create", exportScope)
if err != nil || !created || exportTask.Status != "QUEUED" {
t.Fatalf("export task=%+v created=%v err=%v", exportTask, created, err)
}
replayedExport, created, err := service.CreateExportTask(ctx, owner, subject.ID, "saas-integration-export-key", "saas-integration-export-replay", exportScope)
if err != nil || created || replayedExport.ID != exportTask.ID {
t.Fatalf("export replay=%+v created=%v err=%v", replayedExport, created, err)
}
if _, _, err := service.CreateExportTask(ctx, owner, subject.ID, "saas-integration-export-key", "saas-integration-export-conflict", map[string]any{"categories": []any{"profile"}}); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("export idempotency conflict=%v", err)
}
if _, err := service.ListExportTasks(ctx, editor, subject.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("editor export list error=%v", err)
}
exports, err := service.ListExportTasks(ctx, owner, subject.ID)
if err != nil || len(exports) != 1 || exports[0].ID != exportTask.ID {
t.Fatalf("export tasks=%+v err=%v", exports, err)
}
if _, err := service.GetExportTask(ctx, outsider, exportTask.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider export task error=%v", err)
}
var snapshotCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subject.ID).Scan(&snapshotCount); err != nil {
t.Fatal(err)
}
if snapshotCount != 2 {
t.Fatalf("snapshot count=%d, want 2", snapshotCount)
}
var auditCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id LIKE 'saas-integration-%'`).Scan(&auditCount); err != nil {
t.Fatal(err)
}
if auditCount != 10 {
t.Fatalf("audit count=%d, want 10", auditCount)
}
_ = uuid.MustParse(household.ID)
_ = uuid.MustParse(subject.ID)
_ = uuid.MustParse(consent.ID)
}
func containsHousehold(items []Household, id string) bool {
for _, item := range items {
if item.ID == id {
return true
}
}
return false
}
func TestDeviceReadTwinUpdateAndIsolation(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 := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
deviceID := "10000000-0000-0000-0000-000000000511"
var previousReported []byte
var previousErrorCode, previousErrorSeverity *string
var previousErrorAt *time.Time
var previousUpdatedAt time.Time
if err := pool.QueryRow(ctx, `SELECT reported_config,last_error_code,last_error_severity,last_error_at,updated_at FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID).Scan(&previousReported, &previousErrorCode, &previousErrorSeverity, &previousErrorAt, &previousUpdatedAt); err != nil {
t.Fatalf("read prior device report: %v", err)
}
failureAt := time.Now().UTC().Truncate(time.Second)
if _, err := pool.Exec(ctx, `UPDATE device_twin SET reported_config=jsonb_build_object('telemetry',jsonb_build_object('occurred_at',$2::timestamptz,'status',jsonb_build_object('recording_queue',jsonb_build_object('pending_segments',3,'failed_segments',1,'last_failure_code','MEDIA_UPLOAD_TIMEOUT','last_failure_at',$2::timestamptz)))),last_error_code='MEDIA_UPLOAD_TIMEOUT',last_error_severity='error',last_error_at=$2,updated_at=$2 WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID, failureAt); err != nil {
t.Fatalf("seed device upload queue: %v", err)
}
original, err := service.GetDeviceTwin(ctx, owner, deviceID)
if err != nil {
t.Fatalf("get original twin: %v", err)
}
originalJSON, _ := json.Marshal(original.DesiredConfig)
defer func() {
_, _ = pool.Exec(ctx, `
UPDATE device_twin SET desired_revision=$1,desired_config=$2::jsonb,reported_config=$4::jsonb,
last_error_code=$5,last_error_severity=$6,last_error_at=$7,updated_at=$8
WHERE device_id=(SELECT id FROM device WHERE public_id=$3::uuid)`,
original.DesiredRevision, originalJSON, deviceID, previousReported,
previousErrorCode, previousErrorSeverity, previousErrorAt, previousUpdatedAt)
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-twin'`)
}()
if original.LastErrorCode == nil || *original.LastErrorCode != "MEDIA_UPLOAD_TIMEOUT" || original.LastErrorSeverity == nil || *original.LastErrorSeverity != "error" || original.LastErrorAt == nil {
t.Fatalf("device twin did not expose the latest safe error: %+v", original)
}
telemetry, ok := original.ReportedConfig["telemetry"].(map[string]any)
if !ok || telemetry["status"] == nil {
t.Fatalf("device twin did not expose the upload queue report: %+v", original.ReportedConfig)
}
devices, err := service.ListDevices(ctx, owner)
if err != nil || !containsDevice(devices, deviceID) {
t.Fatalf("list devices: %+v err=%v", devices, err)
}
if _, err := service.GetDevice(ctx, outsider, deviceID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider device error=%v", err)
}
presence, err := service.GetDevicePresence(ctx, owner, deviceID)
if err != nil || presence.DeviceID != deviceID || presence.Source != "database" {
t.Fatalf("database presence: %+v err=%v", presence, err)
}
updated, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-device-twin", original.DesiredRevision, map[string]any{
"volume": map[string]any{"speaker": 65},
"upload": map[string]any{"policy": "wifi_preferred"},
})
if err != nil || updated.DesiredRevision != original.DesiredRevision+1 || updated.SyncState != "pending" {
t.Fatalf("patch device twin: %+v err=%v", updated, err)
}
if updated.DesiredConfig["codec"] != original.DesiredConfig["codec"] || updated.DesiredConfig["sample_rate"] != original.DesiredConfig["sample_rate"] || updated.DesiredConfig["heartbeat_interval_seconds"] != original.DesiredConfig["heartbeat_interval_seconds"] {
t.Fatalf("patch device twin discarded existing device configuration: before=%+v after=%+v", original.DesiredConfig, updated.DesiredConfig)
}
if _, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-device-stale", original.DesiredRevision, map[string]any{}); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale twin patch error=%v", err)
}
}
func containsDevice(items []ManagedDevice, id string) bool {
for _, item := range items {
if item.ID == id {
return true
}
}
return false
}
func TestDeviceClaimIsOneTimeAndRetrySafe(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 := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
householdID := "10000000-0000-0000-0000-000000000211"
subjectID := "10000000-0000-0000-0000-000000000411"
claimCode := "LOCAL-CLAIM-0001"
var bindingID string
defer func() {
_, _ = pool.Exec(ctx, `UPDATE device_claim SET used_at=NULL,used_binding_id=NULL WHERE public_id='10000000-0000-0000-0000-000000000911'::uuid`)
if bindingID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE public_id=$1::uuid`, bindingID)
}
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-claim'`)
}()
if _, _, err := service.ClaimDevice(ctx, outsider, claimCode, householdID, subjectID, "saas-integration-outsider-claim"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider claim error=%v", err)
}
claimed, replayed, err := service.ClaimDevice(ctx, owner, claimCode, householdID, subjectID, "saas-integration-device-claim")
if err != nil || replayed {
t.Fatalf("claim device: %+v replayed=%v err=%v", claimed, replayed, err)
}
bindingID = claimed.BindingID
again, replayed, err := service.ClaimDevice(ctx, owner, claimCode, householdID, subjectID, "saas-integration-device-claim-replay")
if err != nil || !replayed || again.BindingID != bindingID {
t.Fatalf("replay claim: %+v replayed=%v err=%v", again, replayed, err)
}
var activeCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM device_binding WHERE device_id=(SELECT id FROM device WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid) AND unbound_at IS NULL`).Scan(&activeCount); err != nil {
t.Fatal(err)
}
if activeCount != 1 {
t.Fatalf("active binding count=%d", activeCount)
}
}
func TestConversationQueryPaginationHistoryAndIsolation(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()
codec, err := pagination.NewCodec([]byte("integration-pagination-signing-key-32-bytes"))
if err != nil {
t.Fatal(err)
}
service := NewService(pool, audit.NewRepository(), nil, codec)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
subjectID := "10000000-0000-0000-0000-000000000411"
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
conversationIDs := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
conversationPublicIDs := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=ANY($1::uuid[])`, conversationIDs)
_, _ = pool.Exec(ctx, `DELETE FROM conversation_state_history WHERE conversation_id=ANY($1::uuid[])`, conversationIDs)
_, _ = pool.Exec(ctx, `DELETE FROM rtc_task WHERE conversation_id=ANY($1::uuid[])`, conversationIDs)
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=ANY($1::uuid[])`, conversationIDs)
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
}()
_, 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'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'integration-v1','s2s_hybrid',$3,'合成上下文')`, contextID, contextPublicID, "f"+strings.Repeat("0", 63))
if err != nil {
t.Fatalf("insert context snapshot: %v", err)
}
base := time.Now().UTC().Add(-time.Hour)
for index := range conversationIDs {
status := "COMPLETED"
failureCode := any(nil)
rtcStatus := "COMPLETED"
stopReason := any("user_hangup")
if index == 2 {
status = "FAILED"
failureCode = "SYNTHETIC_FAILURE"
rtcStatus = "FAILED"
stopReason = nil
}
created := base.Add(time.Duration(index) * time.Minute)
started := created.Add(time.Second)
ended := started.Add(30 * time.Second)
_, 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,failure_code,created_at,updated_at)
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,
'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,
'10000000-0000-0000-0000-000000000701'::uuid,$3::uuid,$4,'s2s_hybrid','transcript_only',$5,$6,$7,$8,$9,$10,$11,$11)
`, conversationIDs[index], conversationPublicIDs[index], contextID, "integration-task-"+conversationIDs[index], status, created.Add(time.Minute), started, ended, stopReason, failureCode, created)
if err != nil {
t.Fatalf("insert conversation %d: %v", index, err)
}
_, err = pool.Exec(ctx, `INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status) VALUES($1::uuid,'mock',$2,$3,$4,$5,$6)`, conversationIDs[index], "integration-task-"+conversationIDs[index], "room-"+conversationIDs[index], "device-"+conversationIDs[index], "agent-"+conversationIDs[index], rtcStatus)
if err != nil {
t.Fatalf("insert rtc task: %v", err)
}
_, err = pool.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at) VALUES($1::uuid,NULL,'PREPARING','integration','platform',$2),($1::uuid,'PREPARING',$3,'integration','provider',$4)`, conversationIDs[index], created, status, ended)
if err != nil {
t.Fatalf("insert history: %v", err)
}
}
for sequence := 1; sequence <= 3; sequence++ {
start := base.Add(time.Duration(sequence) * time.Second)
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,interrupted,source_type
) VALUES (
'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,$3::bigint,
CASE WHEN $3::bigint%2=1 THEN 'subject' ELSE 'agent' END,$4,$5,$6,false,'provider'
)`, conversationIDs[0], fmt.Sprintf("saas-turn-%d", sequence), sequence, start, start.Add(time.Second), fmt.Sprintf("合成话轮%d", sequence)); err != nil {
t.Fatalf("insert conversation turn: %v", err)
}
}
turns, err := service.ListConversationTurns(ctx, owner, conversationPublicIDs[0], 2, "")
if err != nil || len(turns.Items) != 2 || turns.NextCursor == "" {
t.Fatalf("first turn page=%+v err=%v", turns, err)
}
remainingTurns, err := service.ListConversationTurns(ctx, owner, conversationPublicIDs[0], 2, turns.NextCursor)
if err != nil || len(remainingTurns.Items) != 1 || remainingTurns.NextCursor != "" {
t.Fatalf("second turn page=%+v err=%v", remainingTurns, err)
}
if _, err := service.ListConversationTurns(ctx, outsider, conversationPublicIDs[0], 2, ""); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider conversation turns error=%v", err)
}
if _, err := service.ListConversationTurns(ctx, owner, conversationPublicIDs[0], 2, turns.NextCursor+"x"); !errors.Is(err, pagination.ErrInvalidCursor) {
t.Fatalf("tampered turn cursor error=%v", err)
}
first, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 2})
if err != nil {
t.Fatalf("first page: %v", err)
}
if len(first.Items) != 2 || first.NextCursor == "" {
t.Fatalf("first page=%+v", first)
}
second, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 2, Cursor: first.NextCursor})
if err != nil || len(second.Items) != 1 || second.NextCursor != "" {
t.Fatalf("second page=%+v err=%v", second, err)
}
failed, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 20, Status: "FAILED"})
if err != nil || len(failed.Items) != 1 {
t.Fatalf("failed filter=%+v err=%v", failed, err)
}
detail, err := service.GetConversation(ctx, owner, conversationPublicIDs[2])
if err != nil || len(detail.History) != 2 || detail.Snapshots.ContextSnapshotID != contextPublicID {
t.Fatalf("detail=%+v err=%v", detail, err)
}
if _, err := service.GetConversation(ctx, outsider, conversationPublicIDs[2]); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider conversation error=%v", err)
}
if _, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 2, Cursor: first.NextCursor + "x"}); !errors.Is(err, pagination.ErrInvalidCursor) {
t.Fatalf("tampered cursor error=%v", err)
}
}
func TestContentReviewAndMemoryLifecycle(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 := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
turnID, turnPublicID := uuid.NewString(), uuid.NewString()
storyID, storyPublicID := uuid.NewString(), uuid.NewString()
revisionID := uuid.NewString()
var memoryID string
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'content-integration-%'`)
if memoryID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID)
_, _ = pool.Exec(ctx, `DELETE FROM memory_index_entry WHERE memory_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID)
_, _ = pool.Exec(ctx, `UPDATE curated_memory SET current_version_id=NULL WHERE public_id=$1::uuid`, memoryID)
_, _ = pool.Exec(ctx, `DELETE FROM memory_version WHERE memory_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID)
_, _ = pool.Exec(ctx, `DELETE FROM curated_memory WHERE public_id=$1::uuid`, memoryID)
}
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_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, `UPDATE story_draft SET current_revision_id=NULL WHERE 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 conversation_summary WHERE conversation_id=$1::uuid`, conversationID)
_, _ = 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','[]','content-integration-v1','s2s_hybrid',$3,'合成内容审核上下文')`,
contextID, contextPublicID, "e"+strings.Repeat("0", 63)); err != nil {
t.Fatalf("insert context snapshot: %v", 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 '1 minute',now(),'user_hangup')`,
conversationID, conversationPublicID, contextID, "content-task-"+conversationID); err != nil {
t.Fatalf("insert conversation: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO conversation_turn(id,public_id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$3::uuid,$4,1,'subject',now()-interval '50 seconds',now()-interval '45 seconds',$5,'provider')`,
turnID, turnPublicID, conversationID, "content-turn-"+turnID, "我年轻时在合成城市做过教师。"); err != nil {
t.Fatalf("insert turn: %v", err)
}
sourceIDs, _ := json.Marshal([]string{turnPublicID})
if _, err := pool.Exec(ctx, `
INSERT INTO conversation_summary(tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version)
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,'conversation_summary',$2,$3::jsonb,'mock-content-v1','prompt-v1',1)`,
conversationID, "受试者回忆了教师经历。", sourceIDs); err != nil {
t.Fatalf("insert summary: %v", err)
}
summaries, err := service.ListConversationContentSummaries(ctx, owner, conversationPublicID)
if err != nil || len(summaries) != 1 || len(summaries[0].SourceTurnIDs) != 1 || summaries[0].SourceTurnIDs[0] != turnPublicID {
t.Fatalf("summaries=%+v err=%v", summaries, err)
}
if _, err := service.ListConversationContentSummaries(ctx, outsider, conversationPublicID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider summary error=%v", 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,$4,'CANDIDATE',0.91,'normal',1)`,
storyID, storyPublicID, conversationID, "教师生涯"); err != nil {
t.Fatalf("insert story: %v", 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','mock-content-v1','prompt-v1')`, revisionID, storyID, "受试者年轻时在合成城市从事教师工作。"); err != nil {
t.Fatalf("insert story revision: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=$1::uuid WHERE id=$2::uuid`, revisionID, storyID); err != nil {
t.Fatalf("select current story revision: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO story_source(story_revision_id,conversation_turn_id,source_start_offset,source_end_offset,source_role)
VALUES($1::uuid,$2::uuid,0,15,'subject')`, revisionID, turnID); err != nil {
t.Fatalf("insert story source: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO fact_candidate(tenant_id,subject_id,story_id,fact_type,content,statement_basis,source_turn_ids,status,generation_version)
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,'occupation',$2,'subject_statement',$3::jsonb,'CANDIDATE',1)`,
storyID, "曾从事教师工作", sourceIDs); err != nil {
t.Fatalf("insert fact candidate: %v", err)
}
stories, err := service.ListSubjectStories(ctx, owner, "10000000-0000-0000-0000-000000000411", "CANDIDATE")
if err != nil || len(stories) != 1 || stories[0].ID != storyPublicID {
t.Fatalf("stories=%+v err=%v", stories, err)
}
story, err := service.GetStory(ctx, owner, storyPublicID)
if err != nil || len(story.Sources) != 1 || story.Sources[0].TurnID != turnPublicID || len(story.Facts) != 1 {
t.Fatalf("story=%+v err=%v", story, err)
}
revisedContent := "受试者年轻时在合成城市任职教师,并明确这是本人陈述。"
story, err = service.ReviseStory(ctx, owner, storyPublicID, revisedContent, "人工核对原话", "content-integration-revise", 1)
if err != nil || story.Status != "IN_REVIEW" || story.Version != 2 || story.RevisionNo != 2 || len(story.Sources) != 1 {
t.Fatalf("revised story=%+v err=%v", story, err)
}
if _, err := service.ReviseStory(ctx, owner, storyPublicID, "过期修改", "旧版本", "content-integration-stale", 1); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale story revision error=%v", err)
}
story, memory, err := service.ConfirmStory(ctx, owner, storyPublicID, "来源与本人同意均已核验", "content-integration-confirm", 2)
if err != nil || story.Status != "CONFIRMED" || story.Version != 3 || memory.Status != "ACTIVE" || memory.Content != revisedContent || memory.IndexStatus != "PENDING" {
t.Fatalf("confirmed story=%+v memory=%+v err=%v", story, memory, err)
}
memoryID = memory.ID
if _, _, err := service.ConfirmStory(ctx, owner, storyPublicID, "重复确认", "content-integration-confirm-again", 3); !errors.Is(err, ErrReviewStateConflict) {
t.Fatalf("duplicate story confirmation error=%v", err)
}
memories, err := service.ListSubjectMemories(ctx, owner, "10000000-0000-0000-0000-000000000411", "合成城市")
if err != nil || len(memories) != 1 || memories[0].ID != memory.ID {
t.Fatalf("memories=%+v err=%v", memories, err)
}
if _, err := service.GetMemory(ctx, outsider, memory.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider memory error=%v", err)
}
updatedContent := revisedContent + "(家庭成员复核)"
memory, err = service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-update", 1, UpdateMemoryInput{Content: &updatedContent, Reason: "补充复核结果"})
if err != nil || memory.Version != 2 || memory.VersionNo != 2 || memory.Content != updatedContent || memory.IndexStatus != "PENDING" {
t.Fatalf("updated memory=%+v err=%v", memory, err)
}
if _, err := service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-noop", 2, UpdateMemoryInput{Content: &updatedContent, Reason: "无变化"}); !errors.Is(err, ErrReviewStateConflict) {
t.Fatalf("memory no-op error=%v", err)
}
disabled := "DISABLED"
memory, err = service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-disable", 2, UpdateMemoryInput{Status: &disabled, Reason: "暂停个性化使用"})
if err != nil || memory.Status != "DISABLED" || memory.Version != 3 || memory.IndexStatus != "STALE" {
t.Fatalf("disabled memory=%+v err=%v", memory, err)
}
memory, err = service.RequestMemoryDeletion(ctx, owner, memory.ID, "用户要求删除", "content-integration-memory-delete", 3)
if err != nil || memory.Status != "PENDING_DELETE" || memory.Version != 4 || memory.IndexStatus != "STALE" {
t.Fatalf("pending deletion memory=%+v err=%v", memory, err)
}
active := "ACTIVE"
if _, err := service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-reactivate", 4, UpdateMemoryInput{Status: &active, Reason: "尝试恢复删除中的记忆"}); !errors.Is(err, ErrReviewStateConflict) {
t.Fatalf("pending deletion memory reactivation error=%v", err)
}
replayed, err := service.RequestMemoryDeletion(ctx, owner, memory.ID, "重复请求", "content-integration-memory-delete-replay", 4)
if err != nil || replayed.Version != memory.Version || replayed.Status != "PENDING_DELETE" {
t.Fatalf("replayed memory deletion=%+v err=%v", replayed, err)
}
}
func TestHouseholdMemberInvitationRoleRemovalAndOwnership(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 := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
household, err := service.CreateHousehold(ctx, owner, testTenantPublicID, "成员管理集成家庭", "member-integration-household")
if err != nil {
t.Fatalf("create household: %v", err)
}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'member-integration-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM member_invitation WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, household.ID)
_, _ = pool.Exec(ctx, `DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, household.ID)
_, _ = pool.Exec(ctx, `DELETE FROM household WHERE public_id=$1::uuid`, household.ID)
_, _ = pool.Exec(ctx, `DELETE FROM user_account WHERE tenant_id=(SELECT id FROM tenant WHERE public_id=$1::uuid) AND oidc_issuer=$2 AND oidc_subject=$3`, testTenantPublicID, testIssuer, testOutsideSubject)
}()
if _, err := service.CreateMemberInvitation(ctx, outsider, household.ID, "禁止", identity.RoleViewer, time.Now().Add(time.Hour), "member-integration-outsider"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider invitation error=%v", err)
}
crossTenantInvitation, err := service.CreateMemberInvitation(ctx, owner, household.ID, "跨客户组织受邀账号", identity.RoleViewer, time.Now().Add(time.Hour), "member-integration-cross-tenant-invite")
if err != nil {
t.Fatal(err)
}
crossTenantMember, _, err := service.AcceptMemberInvitation(ctx, outsider, crossTenantInvitation.Token, "member-integration-cross-tenant-accept")
if err != nil || crossTenantMember.Role != identity.RoleViewer {
t.Fatalf("cross-tenant account invitation: %+v err=%v", crossTenantMember, err)
}
invitation, err := service.CreateMemberInvitation(ctx, owner, household.ID, "editor@example.invalid", identity.RoleEditor, time.Now().Add(time.Hour), "member-integration-invite")
if err != nil || invitation.Token == "" || invitation.Status != "pending" {
t.Fatalf("create invitation: %+v err=%v", invitation, err)
}
var storedToken string
if err := pool.QueryRow(ctx, `SELECT token_digest FROM member_invitation WHERE public_id=$1::uuid`, invitation.ID).Scan(&storedToken); err != nil {
t.Fatal(err)
}
if storedToken == invitation.Token || len(storedToken) != 64 {
t.Fatalf("invitation token was not stored as SHA-256 digest: %q", storedToken)
}
invitations, err := service.ListMemberInvitations(ctx, owner, household.ID)
if err != nil || len(invitations) != 2 {
t.Fatalf("member invitations=%+v err=%v", invitations, err)
}
member, replayed, err := service.AcceptMemberInvitation(ctx, editor, invitation.Token, "member-integration-accept")
if err != nil || replayed || member.Role != identity.RoleEditor {
t.Fatalf("accept invitation: %+v replayed=%v err=%v", member, replayed, err)
}
if _, _, err := service.AcceptMemberInvitation(ctx, editor, invitation.Token, "member-integration-reuse"); !errors.Is(err, ErrInvitationInvalid) {
t.Fatalf("reused invitation error=%v", err)
}
var failedAcceptanceAudits int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id='member-integration-reuse' AND action='member.invitation.accept' AND result='failure'`).Scan(&failedAcceptanceAudits); err != nil || failedAcceptanceAudits != 1 {
t.Fatalf("failed invitation audit count=%d err=%v", failedAcceptanceAudits, err)
}
members, err := service.ListHouseholdMembers(ctx, editor, household.ID)
if err != nil || len(members) != 3 {
t.Fatalf("list members: %+v err=%v", members, err)
}
var ownerMember HouseholdMember
for _, item := range members {
if item.IsOwner {
ownerMember = item
}
}
if _, err := service.RemoveHouseholdMember(ctx, owner, household.ID, ownerMember.ID, ownerMember.Version, "member-integration-owner-remove"); !errors.Is(err, ErrOwnerProtected) {
t.Fatalf("owner removal error=%v", err)
}
member, err = service.PatchHouseholdMemberRole(ctx, owner, household.ID, member.ID, identity.RoleAdmin, member.Version, "member-integration-role")
if err != nil || member.Role != identity.RoleAdmin || member.Version != 2 {
t.Fatalf("patch member role: %+v err=%v", member, err)
}
if _, err := service.PatchHouseholdMemberRole(ctx, owner, household.ID, member.ID, identity.RoleViewer, 1, "member-integration-stale"); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale role patch error=%v", err)
}
household, err = service.TransferHouseholdOwnership(ctx, owner, household.ID, member.ID, household.Version, "member-integration-transfer")
if err != nil || household.Version != 2 || household.Role != identity.RoleAdmin {
t.Fatalf("transfer ownership: %+v err=%v", household, err)
}
members, err = service.ListHouseholdMembers(ctx, editor, household.ID)
if err != nil {
t.Fatal(err)
}
for _, item := range members {
if item.UserID == ownerMember.UserID {
ownerMember = item
}
}
household, err = service.TransferHouseholdOwnership(ctx, editor, household.ID, ownerMember.ID, household.Version, "member-integration-transfer-back")
if err != nil || household.Version != 3 {
t.Fatalf("transfer ownership back: %+v err=%v", household, err)
}
members, err = service.ListHouseholdMembers(ctx, owner, household.ID)
if err != nil {
t.Fatal(err)
}
for _, item := range members {
if item.UserID == member.UserID {
member = item
}
}
removed, err := service.RemoveHouseholdMember(ctx, owner, household.ID, member.ID, member.Version, "member-integration-remove")
if err != nil || removed.Status != "removed" {
t.Fatalf("remove member: %+v err=%v", removed, err)
}
revokable, err := service.CreateMemberInvitation(ctx, owner, household.ID, "待撤销", identity.RoleViewer, time.Now().Add(time.Hour), "member-integration-revokable")
if err != nil {
t.Fatal(err)
}
revoked, err := service.RevokeMemberInvitation(ctx, owner, household.ID, revokable.ID, "member-integration-revoke")
if err != nil || revoked.Status != "revoked" {
t.Fatalf("revoke invitation: %+v err=%v", revoked, err)
}
if _, _, err := service.AcceptMemberInvitation(ctx, editor, revokable.Token, "member-integration-revoked-accept"); !errors.Is(err, ErrInvitationInvalid) {
t.Fatalf("revoked invitation error=%v", err)
}
invitations, err = service.ListMemberInvitations(ctx, owner, household.ID)
if err != nil || len(invitations) != 3 {
t.Fatalf("final member invitations=%+v err=%v", invitations, err)
}
}