837 lines
35 KiB
Go
837 lines
35 KiB
Go
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")
|
|
ErrDeviceBindingBlocked = errors.New("device binding change has active blockers")
|
|
ErrDeviceBindingStale = errors.New("device binding changed since impact review")
|
|
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
|
|
providerMemory bool
|
|
}
|
|
|
|
func (s *Service) ConfigureProviderMemory(enabled bool) {
|
|
s.providerMemory = enabled
|
|
}
|
|
|
|
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)
|
|
}
|
|
if s.providerMemory {
|
|
rows, err := tx.Query(ctx, `SELECT id::text FROM curated_memory WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status<>'DELETED' ORDER BY id`, access.tenantID, access.subjectID)
|
|
if err != nil {
|
|
return fmt.Errorf("list provider memories after consent withdrawal: %w", err)
|
|
}
|
|
memoryIDs := make([]string, 0)
|
|
for rows.Next() {
|
|
var memoryID string
|
|
if err := rows.Scan(&memoryID); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
memoryIDs = append(memoryIDs, memoryID)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
for _, memoryID := range memoryIDs {
|
|
dedupe := "memory-provider-delete-consent:" + memoryID + ":" + result.ID
|
|
payload, _ := json.Marshal(map[string]string{"reason": "personalized memory consent withdrawn", "consent_id": result.ID})
|
|
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.provider_delete_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
|
|
return 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 }
|