chore: establish local platform baseline
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(¤tRevisionID); 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, ¤tVersionID,
|
||||
¤tVersionNo, ¤tContent, &sourceRefs, ¤tStatus, &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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤tStatus); 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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¤tRole, &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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user