This commit is contained in:
@@ -62,7 +62,7 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
|
||||
if input.Scope == nil {
|
||||
input.Scope = map[string]any{}
|
||||
}
|
||||
if input.RequestType != "ACCESS" && input.RequestType != "DELETE" {
|
||||
if input.RequestType != "ACCESS" && input.RequestType != "CORRECTION" && input.RequestType != "CONSENT_WITHDRAWAL" && input.RequestType != "DELETE" {
|
||||
return PrivacyRequest{}, false, ErrNotFound
|
||||
}
|
||||
if input.RequestType == "DELETE" {
|
||||
@@ -152,11 +152,115 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
|
||||
created = true
|
||||
eventType := "privacy.access_requested"
|
||||
action := "privacy.access.request"
|
||||
if input.RequestType == "ACCESS" {
|
||||
enqueue := true
|
||||
auditDetail := map[string]any{"subject_id": subjectPublicID, "categories": input.Scope["categories"]}
|
||||
switch input.RequestType {
|
||||
case "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 {
|
||||
case "CORRECTION":
|
||||
corrections, ok := input.Scope["corrections"].(map[string]any)
|
||||
if !ok || len(corrections) == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
preferredName, language, timezone := subject.PreferredName, subject.Language, subject.Timezone
|
||||
profile, preferences := subject.ProfileData, subject.ConversationPreferences
|
||||
changedFields := make([]string, 0, len(corrections))
|
||||
if value, ok := corrections["preferred_name"].(string); ok {
|
||||
preferredName = value
|
||||
changedFields = append(changedFields, "preferred_name")
|
||||
}
|
||||
if value, ok := corrections["language"].(string); ok {
|
||||
language = value
|
||||
changedFields = append(changedFields, "language")
|
||||
}
|
||||
if value, ok := corrections["timezone"].(string); ok {
|
||||
timezone = value
|
||||
changedFields = append(changedFields, "timezone")
|
||||
}
|
||||
if value, ok := corrections["profile_data"].(map[string]any); ok {
|
||||
profile = value
|
||||
changedFields = append(changedFields, "profile_data")
|
||||
}
|
||||
if value, ok := corrections["conversation_preferences"].(map[string]any); ok {
|
||||
preferences = value
|
||||
changedFields = append(changedFields, "conversation_preferences")
|
||||
}
|
||||
if len(changedFields) == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
profileJSON, _ := json.Marshal(profile)
|
||||
preferencesJSON, _ := json.Marshal(preferences)
|
||||
if _, err := tx.Exec(ctx, `UPDATE subject_profile SET preferred_name=$1,language=$2,timezone=$3,profile_data=$4::jsonb,conversation_preferences=$5::jsonb,profile_version=profile_version+1,updated_by=$6::uuid,updated_at=now() WHERE id=$7::uuid AND tenant_id=$8::uuid`, preferredName, language, timezone, profileJSON, preferencesJSON, access.userID, access.subjectID, access.tenantID); err != nil {
|
||||
return fmt.Errorf("apply privacy correction: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref,status,attempts,completed_at) VALUES($1::uuid,'postgresql','subject_profile_correction',$2,'COMPLETED',1,now())`, internalID, subjectPublicID); err != nil {
|
||||
return fmt.Errorf("complete correction subtask: %w", err)
|
||||
}
|
||||
resultJSON, _ := json.Marshal(map[string]any{"content_included": false, "corrected_fields": changedFields, "profile_version": subject.Version + 1})
|
||||
if _, err := tx.Exec(ctx, `UPDATE privacy_request SET status='COMPLETED',result_summary=$2::jsonb,completed_at=now(),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID, resultJSON); err != nil {
|
||||
return fmt.Errorf("complete correction request: %w", err)
|
||||
}
|
||||
enqueue = false
|
||||
action = "privacy.correction.request"
|
||||
auditDetail = map[string]any{"subject_id": subjectPublicID, "corrected_fields": changedFields}
|
||||
case "CONSENT_WITHDRAWAL":
|
||||
requestedTypes := privacyScopeStrings(input.Scope, "consent_types")
|
||||
if len(requestedTypes) == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT consent_type FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type=ANY($3::text[]) AND withdrawn_at IS NULL AND granted_at<=now() AND (expires_at IS NULL OR expires_at>now()) FOR UPDATE`, access.tenantID, access.subjectID, requestedTypes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock consents for privacy withdrawal: %w", err)
|
||||
}
|
||||
withdrawnTypes := make([]string, 0, len(requestedTypes))
|
||||
for rows.Next() {
|
||||
var consentType string
|
||||
if err := rows.Scan(&consentType); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
withdrawnTypes = append(withdrawnTypes, consentType)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
if _, err := tx.Exec(ctx, `UPDATE consent_record SET withdrawn_at=now(),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type=ANY($3::text[]) AND withdrawn_at IS NULL AND granted_at<=now() AND (expires_at IS NULL OR expires_at>now())`, access.tenantID, access.subjectID, requestedTypes); err != nil {
|
||||
return fmt.Errorf("withdraw privacy request consents: %w", err)
|
||||
}
|
||||
if err := createConsentSnapshot(ctx, tx, access.tenantID, access.subjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if containsString(withdrawnTypes, ConsentPersonalMemory) {
|
||||
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 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 err
|
||||
}
|
||||
}
|
||||
stopped := make([]string, 0)
|
||||
for _, consentType := range withdrawnTypes {
|
||||
items, err := s.stopConversationsAfterConsentWithdrawal(ctx, tx, access.tenantID, access.subjectID, consentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stopped = append(stopped, items...)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref,status,attempts,completed_at) VALUES($1::uuid,'postgresql','consent_withdrawal',$2,'COMPLETED',1,now())`, internalID, subjectPublicID); err != nil {
|
||||
return fmt.Errorf("complete consent withdrawal subtask: %w", err)
|
||||
}
|
||||
resultJSON, _ := json.Marshal(map[string]any{"content_included": false, "requested_consent_types": requestedTypes, "withdrawn_consent_types": withdrawnTypes, "stopped_conversation_count": len(stopped)})
|
||||
if _, err := tx.Exec(ctx, `UPDATE privacy_request SET status='COMPLETED',result_summary=$2::jsonb,completed_at=now(),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID, resultJSON); err != nil {
|
||||
return fmt.Errorf("complete consent withdrawal request: %w", err)
|
||||
}
|
||||
enqueue = false
|
||||
action = "privacy.consent_withdrawal.request"
|
||||
auditDetail = map[string]any{"subject_id": subjectPublicID, "requested_consent_types": requestedTypes, "withdrawn_consent_types": withdrawnTypes, "stopped_conversation_ids": stopped}
|
||||
case "DELETE":
|
||||
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 {
|
||||
@@ -178,12 +282,14 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
|
||||
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
|
||||
if enqueue {
|
||||
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}})
|
||||
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: auditDetail})
|
||||
})
|
||||
if err != nil {
|
||||
return PrivacyRequest{}, false, err
|
||||
@@ -192,6 +298,36 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
|
||||
return result, created, err
|
||||
}
|
||||
|
||||
func privacyScopeStrings(scope map[string]any, key string) []string {
|
||||
value, ok := scope[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch values := value.(type) {
|
||||
case []string:
|
||||
return values
|
||||
case []any:
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
if text, ok := item.(string); ok {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user