From 5b37041a0316ab84a0a8ca99d8bc4bbfd5430b32 Mon Sep 17 00:00:00 2001 From: Dohyeop Lim Date: Sun, 13 Sep 2026 21:24:19 +0900 Subject: [PATCH 1/3] fix: require current consent before recording and AI requests --- backend/app/api.py | 132 +++++++++++++++----- backend/app/config.py | 7 +- backend/app/consent.py | 8 ++ backend/app/schemas.py | 1 + backend/app/services/calls.py | 11 ++ backend/app/services/deepgram.py | 1 + backend/app/services/domain.py | 27 ++++- backend/app/services/pipeline.py | 36 ++++++ backend/app/services/tts.py | 6 +- backend/tests/test_account.py | 4 +- backend/tests/test_api_flow.py | 11 ++ backend/tests/test_consent_privacy.py | 166 ++++++++++++++++++++++++++ 12 files changed, 370 insertions(+), 40 deletions(-) create mode 100644 backend/app/consent.py create mode 100644 backend/tests/test_consent_privacy.py diff --git a/backend/app/api.py b/backend/app/api.py index d4ad1d8..f5baf35 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -21,6 +21,7 @@ from app.auth_router import router as auth_router from app.config import Settings +from app.consent import CONSENT_ITEMS from app.container import AppContainer from app.models import ( AcousticAnalysisRun, @@ -62,6 +63,7 @@ ) from app.security import CurrentUser, SessionDep, require_role from app.services.domain import ( + consent_is_current, derived_member_status, ensure_child_can_access_parent, ensure_report_access, @@ -69,6 +71,7 @@ has_consent, latest_consent, latest_invitation, + participants_consented, ) from app.services.livekit import LiveKitError from app.services.notifications import ( @@ -87,14 +90,6 @@ router.include_router(auth_router) logger = logging.getLogger(__name__) -CONSENT_ITEMS = [ - "SENSITIVE_HEALTH_COLLECTION", - "VOICE_FEATURE_EXTRACTION", - "CALL_RECORDING", - "REPORT_SHARING_WITH_CHILD", -] - - def settings_from(request: Request) -> Settings: return request.app.state.container.settings @@ -116,6 +111,7 @@ def call_to_dict(call: CallRecord) -> dict: "endedAt": call.ended_at, "durationSec": call.duration_sec, "recorded": call.recording_enabled, + "recordingEnabled": call.recording_enabled, "parentSpeechSec": call.parent_speech_sec, "askedQuestionIds": call.asked_question_ids, "rawAudioPurgedAt": call.raw_audio_purged_at, @@ -139,12 +135,18 @@ async def recent_question_exclusions(session: SessionDep, parent_id: str) -> set return {question_id for call in calls for question_id in call.asked_question_ids} -async def questions_for_parent(request: Request, session: SessionDep, parent_id: str): +async def questions_for_parent( + request: Request, session: SessionDep, parent_id: str, requester_id: str +): profile = await session.get(ParentProfile, parent_id) conditions = profile.conditions if profile else [] excluded = await recent_question_exclusions(session, parent_id) source, questions = daily_questions(settings_from(request), conditions, excluded, parent_id) - questions = await request.app.state.container.question_tts.attach_audio(questions) + version = settings_from(request).consent_document_version + if await has_consent(session, parent_id, version) and await has_consent( + session, requester_id, version + ): + questions = await request.app.state.container.question_tts.attach_audio(questions) return source, questions @@ -282,6 +284,7 @@ def invitation_dict(invitation: Invitation) -> dict: @router.get("/families/{familyId}/members", tags=["Family"]) async def get_members( family_id: Annotated[str, Path(alias="familyId")], + request: Request, user: CurrentUser, session: SessionDep, ) -> dict: @@ -303,7 +306,9 @@ async def get_members( for member in members: member_user = await session.get(User, member.user_id) if member.user_id else None invitation = await latest_invitation(session, member.id) - member_status = await derived_member_status(session, member, invitation) + member_status = await derived_member_status( + session, member, invitation, settings_from(request).consent_document_version + ) output.append( { "memberId": member.id, @@ -328,7 +333,9 @@ async def get_members( ) owner = await session.get(User, family.created_by) if owner: - owner_consent = await has_consent(session, owner.id) + owner_consent = await has_consent( + session, owner.id, settings_from(request).consent_document_version + ) output.append( { "memberId": owner.id, @@ -387,7 +394,7 @@ async def resend_invitation( @router.post("/invitations/accept", tags=["Family"]) async def accept_invitation( - payload: InvitationAccept, user: CurrentUser, session: SessionDep + payload: InvitationAccept, request: Request, user: CurrentUser, session: SessionDep ) -> dict: require_role(user, UserRole.PARENT) invitation = await session.scalar( @@ -410,7 +417,9 @@ async def accept_invitation( return { "familyId": member.family_id, "memberId": member.id, - "status": await derived_member_status(session, member, invitation), + "status": await derived_member_status( + session, member, invitation, settings_from(request).consent_document_version + ), } if aware(invitation.expires_at) <= datetime.now(UTC): raise HTTPException(status.HTTP_410_GONE, "만료된 초대예요. 다시 초대를 요청해주세요") @@ -442,14 +451,31 @@ async def consent_document(request: Request) -> dict: return { "version": version, "fullText": ( - "통화 중 증상·복약·활동·수면 및 음성 특징값을 수집해 개인의 과거 기록과 " - "비교합니다. 원본 오디오는 분석 직후 즉시 폐기하며 특징값과 구조화 텍스트만 " - "저장합니다. 결과는 의료 진단이나 치료 지시가 아닙니다." + "녹음과 AI 분석은 선택 사항임. 두 참여자가 모두 동의한 통화에서만 음성을 녹음함. " + "거절해도 가족 통화 이용 가능함. Deepgram에 부모와 자녀의 통화 음성을 보내 " + "대화를 글로 변환함. Google Gemini에 두 참여자의 전사문과 그 안의 증상, 복약, " + "활동, 수면 정보를 보내 건강 기록을 생성함. ElevenLabs에는 질문 문장을 보내 " + "질문 음성을 생성하며, 아이폰 직접 요청 시 IP 주소가 전달됨. " + "이름, 전화번호, Apple 로그인 토큰은 AI 요청에 포함하지 않지만 대화에 말한 " + "개인정보는 음성과 전사문에 포함될 수 있음. 해외 제공사 서버에서 처리될 수 있음. " + "부모의 건강 기록과 음성 특징값은 가족 자녀에게 제공함. " + "의료 진단이나 치료 용도가 아님. " + "콜록 원본 음성은 분석 후 삭제하며 실패하거나 남은 파일은 자동 정리함. " + "전사문과 건강 기록은 계정 삭제 시까지 보관함. 외부 제공사의 보관 기간과 " + "삭제 처리는 해당 계약 및 정책에 따르며 콜록 서버 삭제와 별개임. " + "Deepgram 요청에는 모델 개선 참여 제외 옵션을 사용함. Google의 유료 서비스 " + "데이터 처리 조건을 운영자가 확인하기 전에는 녹음과 건강 분석을 비활성화함. " + "ElevenLabs는 모델 학습 이용을 제외한 계정 설정으로 사용함. " + "외부 음성 서비스를 사용할 수 없으면 아이폰 기본 음성으로 재생함. " + "설정에서 동의를 변경할 수 있으며 거절하면 이후 녹음과 AI 분석을 중단함." ), - "collectedItems": ["증상", "복약", "활동", "수면", "음성 특징값"], + "collectedItems": [ + "통화 음성", "화자별 전사문", "증상", "복약", "활동", "수면", "음성 특징값", + "질문 문장", "질문 음성 요청 시 IP 주소", + ], "purpose": "가족 통화 기반 건강 변화 기록과 리포트 제공", - "retentionPeriod": "동의 철회 시까지", - "rawAudioPolicy": "원본 오디오는 분석 직후 즉시 폐기합니다", + "retentionPeriod": "전사문과 건강 기록은 계정 삭제 시까지 보관함", + "rawAudioPolicy": "콜록 원본 음성은 분석 후 삭제함. 외부 서비스 보관 정책은 별도임", "requiredItems": CONSENT_ITEMS, } @@ -458,11 +484,27 @@ async def consent_document(request: Request) -> dict: async def submit_consent( payload: ConsentSubmit, request: Request, user: CurrentUser, session: SessionDep ) -> dict: - require_role(user, UserRole.PARENT) + async with request.app.state.container.calls.reserve_participants([user.id]): + await session.execute(select(User.id).where(User.id == user.id).with_for_update()) + return await save_consent(payload, request, user, session) + + +async def save_consent( + payload: ConsentSubmit, request: Request, user: CurrentUser, session: SessionDep +) -> dict: + busy = await session.scalar(select(CallRecord.id).where( + or_(CallRecord.parent_id == user.id, CallRecord.child_id == user.id), + CallRecord.state.in_([ + CallState.CREATED.value, CallState.RINGING.value, CallState.ACTIVE.value, + ]), + CallRecord.ended_at.is_(None), + ).limit(1)) + if busy is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "통화를 종료한 뒤 동의를 변경해주세요") settings = settings_from(request) if payload.document_version != settings.consent_document_version: raise HTTPException(status.HTTP_409_CONFLICT, "최신 동의 안내를 다시 확인해주세요") - if not payload.scrolled_to_end: + if payload.decision == "GRANT" and not payload.scrolled_to_end: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "안내 내용을 끝까지 확인해주세요") if payload.decision == "GRANT" and not set(CONSENT_ITEMS).issubset(payload.agreed_items): raise HTTPException( @@ -481,10 +523,10 @@ async def submit_consent( ) session.add(record) await session.commit() - return consent_dict(record) + return consent_dict(record, settings.consent_document_version) -def consent_dict(record: ConsentRecord) -> dict: +def consent_dict(record: ConsentRecord, version: str) -> dict: return { "consentId": record.id, "userId": record.user_id, @@ -492,15 +534,17 @@ def consent_dict(record: ConsentRecord) -> dict: "status": record.decision, "agreedItems": record.agreed_items, "agreedAt": record.agreed_at, + "isCurrent": consent_is_current(record, version), + "currentDocumentVersion": version, } @router.get("/consents/me", tags=["Consent"]) -async def my_consent(user: CurrentUser, session: SessionDep) -> dict: +async def my_consent(request: Request, user: CurrentUser, session: SessionDep) -> dict: record = await latest_consent(session, user.id) if record is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "동의 기록이 없습니다") - return consent_dict(record) + return consent_dict(record, settings_from(request).consent_document_version) # Profile and questions @@ -525,6 +569,7 @@ async def get_profile( async def put_profile( parent_id: Annotated[str, Path(alias="parentId")], payload: ProfilePut, + request: Request, user: CurrentUser, session: SessionDep, ) -> dict: @@ -532,7 +577,7 @@ async def put_profile( await ensure_child_can_access_parent(session, user, parent_id) elif user.id != parent_id: raise HTTPException(status.HTTP_403_FORBIDDEN, "프로필 접근 권한이 없습니다") - if not await has_consent(session, parent_id): + if not await has_consent(session, parent_id, settings_from(request).consent_document_version): raise HTTPException(status.HTTP_409_CONFLICT, "부모님 동의가 완료되어야 등록할 수 있어요") profile = await session.get(ParentProfile, parent_id) if profile is None: @@ -557,7 +602,7 @@ async def get_daily_questions( session: SessionDep, ) -> dict: await ensure_report_access(session, user, parent_id) - source, questions = await questions_for_parent(request, session, parent_id) + source, questions = await questions_for_parent(request, session, parent_id, user.id) return {"source": source, "questions": [item.model_dump(by_alias=True) for item in questions]} @@ -629,13 +674,21 @@ async def create_reserved_call( raise HTTPException( status.HTTP_409_CONFLICT, "상대방의 통화 수신 기기가 등록되지 않았습니다" ) - source, questions = await questions_for_parent(request, session, parent.id) + source, questions = await questions_for_parent(request, session, parent.id, child.id) del source - recording_enabled = await has_consent(session, parent.id) + recording_enabled = await participants_consented( + session, parent.id, child.id, settings_from(request).consent_document_version + ) latest = await latest_consent(session, parent.id) disabled_reason = None if not recording_enabled: disabled_reason = "CONSENT_DENIED" if latest else "CONSENT_PENDING" + if ( + not settings_from(request).mock_external_services + and not settings_from(request).gemini_data_processing_approved + ): + recording_enabled = False + disabled_reason = "PROVIDER_PRIVACY_PENDING" call = CallRecord( parent_id=parent.id, child_id=child.id, @@ -698,7 +751,7 @@ async def create_reserved_call( access_token=token, recording_enabled=recording_enabled, recording_disabled_reason=disabled_reason, - recording_disabled_message="동의가 완료되면 기록할 수 있어요" if disabled_reason else None, + recording_disabled_message="녹음과 AI 분석 없이 통화해요" if disabled_reason else None, questions=questions, audio_constraints=AudioConstraints(), ) @@ -726,6 +779,10 @@ async def create_question_tts_token( raise HTTPException( status.HTTP_403_FORBIDDEN, "발신자만 질문 음성을 요청할 수 있습니다" ) + if not await participants_consented( + session, call.parent_id, call.child_id, container.settings.consent_document_version + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, "두 참여자의 AI 처리 동의가 필요해요") if call.state != CallState.RINGING.value or call.ended_at is not None: raise HTTPException( status.HTTP_409_CONFLICT, "수신 대기 중에만 질문 음성을 요청할 수 있습니다" @@ -737,6 +794,11 @@ async def create_question_tts_token( if question_id not in call.asked_question_ids: raise HTTPException(status.HTTP_404_NOT_FOUND, "통화 질문을 찾을 수 없습니다") gateway = container.question_tts + if ( + not container.settings.mock_external_services + and not container.settings.elevenlabs_data_processing_approved + ): + raise HTTPException(status.HTTP_409_CONFLICT, "아이폰 기본 음성으로 재생해주세요") if not isinstance(gateway, ElevenLabsDirectTtsGateway): raise HTTPException(status.HTTP_409_CONFLICT, "직접 음성 재생이 설정되지 않았습니다") question_count = await session.scalar( @@ -784,9 +846,14 @@ async def accept_call( datetime.now(UTC) - aware(call.started_at) ).total_seconds() >= settings.incoming_call_ttl_seconds: raise HTTPException(status.HTTP_410_GONE, "수신 대기 시간이 만료되었습니다") - if call.recording_enabled and not await has_consent(session, call.parent_id): + if call.recording_enabled and not await participants_consented( + session, call.parent_id, call.child_id, settings.consent_document_version + ): call.recording_enabled = False call.recording_disabled_reason = "CONSENT_DENIED" + if not settings.mock_external_services and not settings.gemini_data_processing_approved: + call.recording_enabled = False + call.recording_disabled_reason = "PROVIDER_PRIVACY_PENDING" call.state = CallState.ACTIVE.value call.accepted_at = datetime.now(UTC) settings = settings_from(request) @@ -810,6 +877,7 @@ async def accept_call( background.add_task(request.app.state.container.calls.start_recordings, call.id) response = CallAccepted( call_id=call.id, + recording_enabled=call.recording_enabled, livekit_url=settings.livekit_url, room_name=call.room_name, access_token=token, diff --git a/backend/app/config.py b/backend/app/config.py index 9b157b5..e983519 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,6 +34,9 @@ class Settings(BaseSettings): apple_login_enabled: bool = True apple_client_id: str = "com.dohyeoplim.collog-ios" apple_challenge_ttl_seconds: int = Field(default=300, ge=30, le=600) + apple_team_id: str = "" + apple_key_id: str = "" + apple_private_key_path: Path | None = None schema_auto_reset: bool = False @@ -60,12 +63,14 @@ class Settings(BaseSettings): deepgram_base_url: str = "https://api.deepgram.com" gemini_api_key: str = "" + gemini_data_processing_approved: bool = False gemini_model: str = "gemini-3.6-flash" gemini_base_url: str = "https://generativelanguage.googleapis.com" gemini_max_output_tokens: int = 2048 question_tts_provider: Literal["ios_local", "elevenlabs", "elevenlabs_direct"] = "ios_local" elevenlabs_api_key: str = "" + elevenlabs_data_processing_approved: bool = False elevenlabs_voice_id: str = "" elevenlabs_model: str = "eleven_flash_v2_5" elevenlabs_output_format: str = "mp3_44100_128" @@ -84,7 +89,7 @@ class Settings(BaseSettings): s3_use_instance_role: bool = False s3_force_path_style: bool = True - consent_document_version: str = "2026-08-01.v3" + consent_document_version: str = "2026-09-13.v4" parent_min_speech_seconds: int = 20 raw_audio_wait_seconds: int = 30 egress_wait_seconds: int = Field(default=120, ge=1) diff --git a/backend/app/consent.py b/backend/app/consent.py new file mode 100644 index 0000000..bb74d79 --- /dev/null +++ b/backend/app/consent.py @@ -0,0 +1,8 @@ +CONSENT_VERSION = "2026-09-13.v4" +CONSENT_ITEMS = [ + "SENSITIVE_HEALTH_COLLECTION", + "VOICE_FEATURE_EXTRACTION", + "CALL_RECORDING", + "REPORT_SHARING_WITH_CHILD", + "THIRD_PARTY_AI_PROCESSING", +] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2b81011..418d960 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -168,6 +168,7 @@ class CallCreated(ApiModel): class CallAccepted(ApiModel): call_id: str + recording_enabled: bool livekit_url: str room_name: str access_token: str diff --git a/backend/app/services/calls.py b/backend/app/services/calls.py index 170c776..95d96f9 100644 --- a/backend/app/services/calls.py +++ b/backend/app/services/calls.py @@ -49,6 +49,8 @@ async def reserve_participants(self, user_ids: list[str]) -> AsyncIterator[None] yield async def start_recordings(self, call_id: str, tracks: dict[str, str] | None = None) -> None: + from app.services.domain import participants_consented + if self.settings.allow_raw_only_analysis: return async with self.database.sessions() as session: @@ -57,6 +59,15 @@ async def start_recordings(self, call_id: str, tracks: dict[str, str] | None = N ) if call is None or call.state != CallState.ACTIVE.value or not call.recording_enabled: return + if ( + not self.settings.mock_external_services + and not self.settings.gemini_data_processing_approved + ) or not await participants_consented( + session, call.parent_id, call.child_id, self.settings.consent_document_version + ): + call.recording_enabled = False + await session.commit() + return assets = list( await session.scalars( select(AudioAsset).where( diff --git a/backend/app/services/deepgram.py b/backend/app/services/deepgram.py index 54fbd01..e386564 100644 --- a/backend/app/services/deepgram.py +++ b/backend/app/services/deepgram.py @@ -87,6 +87,7 @@ async def transcribe( "punctuate": True, "utterances": True, "filler_words": True, + "mip_opt_out": True, } headers = { "Authorization": f"Token {self.settings.deepgram_api_key}", diff --git a/backend/app/services/domain.py b/backend/app/services/domain.py index b9ea951..559defe 100644 --- a/backend/app/services/domain.py +++ b/backend/app/services/domain.py @@ -6,6 +6,7 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.consent import CONSENT_ITEMS, CONSENT_VERSION from app.models import ( ConsentDecision, ConsentRecord, @@ -48,9 +49,26 @@ async def latest_consent(session: AsyncSession, parent_id: str) -> ConsentRecord ) -async def has_consent(session: AsyncSession, parent_id: str) -> bool: +def consent_is_current(record: ConsentRecord | None, version: str = CONSENT_VERSION) -> bool: + return record is not None and record.document_version == version and ( + record.decision == ConsentDecision.DENIED.value + or set(CONSENT_ITEMS).issubset(record.agreed_items) + ) + + +async def has_consent( + session: AsyncSession, parent_id: str, version: str = CONSENT_VERSION +) -> bool: record = await latest_consent(session, parent_id) - return record is not None and record.decision == ConsentDecision.GRANTED.value + return consent_is_current(record, version) and record.decision == ConsentDecision.GRANTED.value + + +async def participants_consented( + session: AsyncSession, parent_id: str, child_id: str, version: str = CONSENT_VERSION +) -> bool: + return await has_consent(session, parent_id, version) and await has_consent( + session, child_id, version + ) async def latest_invitation(session: AsyncSession, member_id: str) -> Invitation | None: @@ -63,11 +81,12 @@ async def latest_invitation(session: AsyncSession, member_id: str) -> Invitation async def derived_member_status( - session: AsyncSession, member: FamilyMember, invitation: Invitation | None = None + session: AsyncSession, member: FamilyMember, invitation: Invitation | None = None, + version: str = CONSENT_VERSION, ) -> str: if member.user_id: consent = await latest_consent(session, member.user_id) - if consent: + if consent_is_current(consent, version): return "CONSENT_GRANTED" if consent.decision == "GRANTED" else "CONSENT_DENIED" return "AWAITING_CONSENT" invitation = invitation or await latest_invitation(session, member.id) diff --git a/backend/app/services/pipeline.py b/backend/app/services/pipeline.py index d274f79..3759843 100644 --- a/backend/app/services/pipeline.py +++ b/backend/app/services/pipeline.py @@ -23,6 +23,7 @@ ) from app.services.acoustics import AcousticAnalysisInput, AcousticAnalyzer from app.services.deepgram import SttGateway, SttResult +from app.services.domain import participants_consented from app.services.gemini import ExtractionGateway from app.services.repeat_detector import detect_repeat_events from app.services.signals import SignalService @@ -32,6 +33,24 @@ class ProcessingPipeline: + async def processing_allowed(self, call_id: str) -> bool: + async with self.database.sessions() as session: + call = await session.get(CallRecord, call_id) + allowed = call is not None and call.recording_enabled and ( + self.settings.mock_external_services + or self.settings.gemini_data_processing_approved + ) and await participants_consented( + session, call.parent_id, call.child_id, self.settings.consent_document_version + ) + if allowed: + return True + if call is not None: + call.state = CallState.ANALYSIS_EXCLUDED.value + call.processing_claimed_at = None + await session.commit() + await self.purge_call_audio(call_id) + return False + def log_stt_result(self, call_id: str, speaker: str, result: SttResult) -> None: logger.info( "STT %s call=%s provider=%s speech=%.1fs segments=%d words=%d", @@ -108,6 +127,17 @@ async def _process(self, call_id: str, claimed_at: datetime) -> None: return if not call.recording_enabled or call.ended_at is None: return + if ( + not self.settings.mock_external_services + and not self.settings.gemini_data_processing_approved + ) or not await participants_consented( + session, call.parent_id, call.child_id, self.settings.consent_document_version + ): + call.state = CallState.ANALYSIS_EXCLUDED.value + call.recording_enabled = False + await session.commit() + await self.purge_call_audio(call_id) + return assets = ( await session.scalars(select(AudioAsset).where(AudioAsset.call_id == call_id)) ).all() @@ -218,11 +248,15 @@ async def _process(self, call_id: str, claimed_at: datetime) -> None: # 자녀 음성이 없으므로 transcript에는 부모 발화만 남는다. parent_source = parent_egress or raw_asset parent_audio = await self.storage.read(parent_source.uri) + if not await self.processing_allowed(call_id): + return parent_stt = await self.stt.transcribe(parent_audio, parent_source.content_type, "PARENT") self.log_stt_result(call_id, "PARENT", parent_stt) stt_results = [("PARENT", parent_stt)] if child_egress: child_audio = await self.storage.read(child_egress.uri) + if not await self.processing_allowed(call_id): + return child_stt = await self.stt.transcribe(child_audio, child_egress.content_type, "CHILD") self.log_stt_result(call_id, "CHILD", child_stt) stt_results.append(("CHILD", child_stt)) @@ -303,6 +337,8 @@ async def _process(self, call_id: str, claimed_at: datetime) -> None: return transcript_text = "\n".join(f"{item['speaker']}: {item['text']}" for item in segments) + if not await self.processing_allowed(call_id): + return try: extracted = await self.extraction.extract(segments) extraction_values = extracted.model_dump() diff --git a/backend/app/services/tts.py b/backend/app/services/tts.py index 646b254..85f03d2 100644 --- a/backend/app/services/tts.py +++ b/backend/app/services/tts.py @@ -168,7 +168,11 @@ async def issue_token(self) -> QuestionTtsToken: def create_question_tts_gateway( settings: Settings, storage: StorageGateway ) -> QuestionTtsGateway: - if settings.question_tts_provider == "ios_local" or settings.mock_external_services: + if ( + settings.question_tts_provider == "ios_local" + or settings.mock_external_services + or not settings.elevenlabs_data_processing_approved + ): return QuestionTtsGateway() try: if settings.question_tts_provider == "elevenlabs_direct": diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py index 81073f2..039cd7e 100644 --- a/backend/tests/test_account.py +++ b/backend/tests/test_account.py @@ -50,7 +50,7 @@ async def check() -> None: client.portal.call(check) -def test_role_change_keeps_history_and_requires_consent_for_new_parent(client: TestClient) -> None: +def test_role_change_keeps_history_and_current_consent(client: TestClient) -> None: child_token, child, parent_token, parent = onboard_family(client) async def seed() -> str: @@ -73,7 +73,7 @@ async def seed() -> str: ).json()["members"] assert next(member for member in members if member["userId"] == child["id"])[ "status" - ] == "AWAITING_CONSENT" + ] == "CONSENT_GRANTED" async def check() -> None: async with client.app.state.container.database.sessions() as session: diff --git a/backend/tests/test_api_flow.py b/backend/tests/test_api_flow.py index 45183d0..362564c 100644 --- a/backend/tests/test_api_flow.py +++ b/backend/tests/test_api_flow.py @@ -57,6 +57,17 @@ def onboard_family(client: TestClient): }, ) assert consented.status_code == 201, consented.text + child_consent = client.post( + "/v1/consents", + headers=auth(child_token), + json={ + "documentVersion": document["version"], + "decision": "GRANT", + "scrolledToEnd": True, + "agreedItems": document["requiredItems"], + }, + ) + assert child_consent.status_code == 201, child_consent.text return child_token, child, parent_token, parent diff --git a/backend/tests/test_consent_privacy.py b/backend/tests/test_consent_privacy.py new file mode 100644 index 0000000..8c70065 --- /dev/null +++ b/backend/tests/test_consent_privacy.py @@ -0,0 +1,166 @@ +from datetime import UTC, datetime + +import httpx +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.models import CallRecord, ConsentRecord +from app.services.deepgram import DeepgramSttGateway +from app.services.tts import ElevenLabsDirectTtsGateway, create_question_tts_gateway +from tests.conftest import auth +from tests.test_api_flow import onboard_family + + +def test_child_decline_allows_unrecorded_call_and_blocks_tts(client: TestClient) -> None: + child_token, _, parent_token, parent = onboard_family(client) + document = client.get("/v1/consents/document").json() + declined = client.post( + "/v1/consents", + headers=auth(child_token), + json={ + "documentVersion": document["version"], + "decision": "DENY", + "scrolledToEnd": False, + "agreedItems": [], + }, + ) + assert declined.status_code == 201 + assert declined.json()["isCurrent"] is True + call = client.post("/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]}) + assert call.status_code == 201 + assert call.json()["recordingEnabled"] is False + call_id = call.json()["callId"] + question = call.json()["questions"][0] + assert question["ttsMode"] == "IOS_LOCAL" + token = client.post( + f"/v1/calls/{call_id}/questions/{question['questionId']}/tts-token", + headers=auth(child_token), + ) + assert token.status_code == 403 + accepted = client.post(f"/v1/calls/{call_id}/accept", headers=auth(parent_token)) + assert accepted.status_code == 200 + assert accepted.json()["recordingEnabled"] is False + assert accepted.json()["rawCaptureRequired"] is False + + +@pytest.mark.parametrize("outdated", [True, False]) +def test_old_or_incomplete_grant_requires_new_permission( + client: TestClient, outdated: bool +) -> None: + child_token, child, _, parent = onboard_family(client) + + async def change_record() -> None: + async with client.app.state.container.database.sessions() as session: + record = await session.scalar( + select(ConsentRecord).where(ConsentRecord.user_id == child["id"]) + ) + if outdated: + record.document_version = "2026-08-01.v3" + else: + record.agreed_items = ["CALL_RECORDING"] + await session.commit() + + client.portal.call(change_record) + assert client.get("/v1/consents/me", headers=auth(child_token)).json()["isCurrent"] is False + call = client.post("/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]}) + assert call.status_code == 201 + assert call.json()["recordingEnabled"] is False + + +def test_accept_rechecks_consent_and_queued_analysis_stops(client: TestClient, monkeypatch) -> None: + child_token, child, parent_token, parent = onboard_family(client) + call = client.post( + "/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]} + ).json() + assert call["recordingEnabled"] is True + + async def expire_consent() -> None: + async with client.app.state.container.database.sessions() as session: + record = await session.scalar( + select(ConsentRecord).where(ConsentRecord.user_id == child["id"]) + ) + record.document_version = "old" + await session.commit() + + client.portal.call(expire_consent) + accepted = client.post(f"/v1/calls/{call['callId']}/accept", headers=auth(parent_token)) + assert accepted.json()["recordingEnabled"] is False + + async def seed_queued() -> None: + async with client.app.state.container.database.sessions() as session: + stored = await session.get(CallRecord, call["callId"]) + stored.recording_enabled = True + stored.ended_at = datetime.now(UTC) + stored.state = "ENDED" + await session.commit() + + client.portal.call(seed_queued) + pipeline = client.app.state.container.pipeline + assert client.portal.call(pipeline.processing_allowed, call["callId"]) is False + + +async def test_deepgram_excludes_model_improvement(client: TestClient, monkeypatch) -> None: + settings = client.app.state.container.settings.model_copy(update={"deepgram_api_key": "test"}) + gateway = DeepgramSttGateway(settings) + + async def respond(client, method, url, **kwargs): + assert kwargs["params"]["mip_opt_out"] is True + return httpx.Response( + 200, + json={ + "results": {"channels": [{"alternatives": [{"transcript": "", "words": []}]}]}, + }, + ) + + monkeypatch.setattr("app.services.deepgram.request_with_retry", respond) + await gateway.transcribe(b"audio", "audio/wav", "PARENT") + + +def test_unapproved_provider_blocks_queued_processing(client: TestClient) -> None: + child_token, _, _, parent = onboard_family(client) + call = client.post( + "/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]} + ).json() + pipeline = client.app.state.container.pipeline + pipeline.settings = pipeline.settings.model_copy( + update={ + "mock_external_services": False, + "gemini_data_processing_approved": False, + } + ) + assert client.portal.call(pipeline.processing_allowed, call["callId"]) is False + + +def test_consent_change_during_call_requires_ending_call(client: TestClient) -> None: + child_token, _, _, parent = onboard_family(client) + client.post("/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]}) + document = client.get("/v1/consents/document").json() + response = client.post( + "/v1/consents", + headers=auth(child_token), + json={ + "documentVersion": document["version"], + "decision": "DENY", + "scrolledToEnd": False, + "agreedItems": [], + }, + ) + assert response.status_code == 409 + assert client.get("/v1/consents/me", headers=auth(child_token)).json()["status"] == "GRANTED" + + +@pytest.mark.parametrize("approved", [False, True]) +def test_remote_voice_requires_provider_approval(client: TestClient, approved: bool) -> None: + container = client.app.state.container + settings = container.settings.model_copy( + update={ + "mock_external_services": False, + "question_tts_provider": "elevenlabs_direct", + "elevenlabs_api_key": "test-key", + "elevenlabs_voice_id": "test-voice", + "elevenlabs_data_processing_approved": approved, + } + ) + gateway = create_question_tts_gateway(settings, container.storage) + assert isinstance(gateway, ElevenLabsDirectTtsGateway) is approved From f8002ab41ecc502a0e60c234b61c48c11ed9a31e Mon Sep 17 00:00:00 2001 From: Dohyeop Lim Date: Sun, 13 Sep 2026 21:24:19 +0900 Subject: [PATCH 2/3] feat: revoke Apple access during account deletion --- backend/app/account_router.py | 65 ++++++++++- backend/app/services/apple_oauth.py | 85 +++++++++++++++ backend/tests/test_apple_deletion.py | 156 +++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/apple_oauth.py create mode 100644 backend/tests/test_apple_deletion.py diff --git a/backend/app/account_router.py b/backend/app/account_router.py index 0809fe7..e34a48f 100644 --- a/backend/app/account_router.py +++ b/backend/app/account_router.py @@ -1,14 +1,18 @@ from __future__ import annotations +import hashlib +import hmac from datetime import UTC, datetime, timedelta from botocore.exceptions import BotoCoreError, ClientError from fastapi import APIRouter, HTTPException, Request, Response -from sqlalchemy import delete, or_, select +from pydantic import Field +from sqlalchemy import delete, or_, select, update from app.models import ( AcousticAnalysisRun, AcousticFeature, + AppleLoginChallenge, AssetKind, AudioAsset, Baseline, @@ -34,6 +38,8 @@ ) from app.schemas import ApiModel, UserView from app.security import CurrentUser, SessionDep +from app.services.apple_auth import AppleIdentityError, AppleServiceError +from app.services.apple_oauth import apple_client_secret, revoke_apple_authorization from app.services.domain import family_of from app.services.storage import StorageError @@ -44,6 +50,12 @@ class RoleUpdate(ApiModel): role: UserRole +class AppleDeletionRequest(ApiModel): + challenge_id: str = Field(min_length=1, max_length=255) + identity_token: str = Field(min_length=1, max_length=16384) + authorization_code: str = Field(min_length=1, max_length=4096) + + async def lock_account(session: SessionDep, user: User, *, deleting: bool = False) -> None: locked = await session.scalar(select(User).where(User.id == user.id).with_for_update()) if locked is None: @@ -89,9 +101,47 @@ async def update_role( @router.delete("", status_code=204) -async def delete_account(request: Request, user: CurrentUser, session: SessionDep) -> Response: +async def delete_account( + request: Request, user: CurrentUser, session: SessionDep, + payload: AppleDeletionRequest | None = None, +) -> Response: async with request.app.state.container.calls.reserve_participants([user.id]): await lock_account(session, user, deleting=True) + container = request.app.state.container + identity = None + client_secret = None + if user.apple_subject: + if payload is None: + raise HTTPException(400, "계정 삭제를 위해 Apple 인증을 다시 진행해주세요") + try: + client_secret = apple_client_secret(container.settings) + identity = await container.apple_identity.verify(payload.identity_token) + except AppleIdentityError as exc: + raise HTTPException(401, "Apple 인증을 다시 진행해주세요") from exc + except AppleServiceError as exc: + raise HTTPException( + 503, "Apple 계정 삭제를 준비 중이에요. 잠시 후 다시 시도해주세요" + ) from exc + challenge = await session.get(AppleLoginChallenge, payload.challenge_id) + if ( + identity.subject != user.apple_subject or challenge is None + or challenge.consumed_at is not None + or challenge.expires_at.replace(tzinfo=UTC) <= datetime.now(UTC) + or not hmac.compare_digest( + challenge.nonce_hash, hashlib.sha256(identity.nonce.encode()).hexdigest() + ) + ): + raise HTTPException(401, "현재 계정으로 Apple 인증을 다시 진행해주세요") + consumed = await session.scalar( + update(AppleLoginChallenge).where( + AppleLoginChallenge.id == payload.challenge_id, + AppleLoginChallenge.consumed_at.is_(None), + AppleLoginChallenge.expires_at > datetime.now(UTC), + ).values(consumed_at=datetime.now(UTC)) + .execution_options(synchronize_session=False).returning(AppleLoginChallenge.id) + ) + if consumed is None: + raise HTTPException(401, "Apple 인증을 다시 진행해주세요") calls = list(await session.scalars( select(CallRecord).where( or_(CallRecord.parent_id == user.id, CallRecord.child_id == user.id) @@ -140,5 +190,16 @@ async def delete_account(request: Request, user: CurrentUser, session: SessionDe if user.phone: await session.execute(delete(OtpChallenge).where(OtpChallenge.phone == user.phone)) await session.execute(delete(User).where(User.id == user.id)) + await session.flush() + if identity is not None and payload is not None and client_secret is not None: + try: + await revoke_apple_authorization( + container.settings, container.apple_identity, code=payload.authorization_code, + subject=identity.subject, nonce=identity.nonce, client_secret=client_secret, + ) + except (AppleIdentityError, AppleServiceError) as exc: + raise HTTPException( + 503, "Apple 권한 해제를 완료하지 못했어요. 다시 인증 후 삭제해주세요" + ) from exc await session.commit() return Response(status_code=204) diff --git a/backend/app/services/apple_oauth.py b/backend/app/services/apple_oauth.py new file mode 100644 index 0000000..d45f84d --- /dev/null +++ b/backend/app/services/apple_oauth.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import time + +import httpx +import jwt + +from app.config import Settings +from app.services.apple_auth import AppleIdentityVerifier, AppleServiceError + + +def apple_client_secret(settings: Settings) -> str: + if not all((settings.apple_team_id, settings.apple_key_id, settings.apple_private_key_path)): + raise AppleServiceError("Apple account deletion is not configured") + try: + key = settings.apple_private_key_path.read_text() + now = int(time.time()) + return jwt.encode( + { + "iss": settings.apple_team_id, + "iat": now, + "exp": now + 300, + "aud": "https://appleid.apple.com", + "sub": settings.apple_client_id, + }, + key, + algorithm="ES256", + headers={"kid": settings.apple_key_id}, + ) + except (OSError, ValueError, jwt.PyJWTError) as exc: + raise AppleServiceError("Apple account deletion credentials are unavailable") from exc + + +async def revoke_apple_authorization( + settings: Settings, + verifier: AppleIdentityVerifier, + *, + code: str, + subject: str, + nonce: str, + client_secret: str, + client: httpx.AsyncClient | None = None, +) -> None: + owns_client = client is None + client = client or httpx.AsyncClient(timeout=10) + try: + response = await client.post( + "https://appleid.apple.com/auth/token", + data={ + "client_id": settings.apple_client_id, + "client_secret": client_secret, + "code": code, + "grant_type": "authorization_code", + }, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Invalid Apple token response") + identity_token = payload.get("id_token") + refresh_token = payload.get("refresh_token") + if ( + not isinstance(identity_token, str) + or not isinstance(refresh_token, str) + or not refresh_token + ): + raise ValueError("Missing Apple tokens") + identity = await verifier.verify(identity_token) + if identity.subject != subject or identity.nonce != nonce: + raise ValueError("Apple authorization does not match this account") + revoked = await client.post( + "https://appleid.apple.com/auth/revoke", + data={ + "client_id": settings.apple_client_id, + "client_secret": client_secret, + "token": refresh_token, + "token_type_hint": "refresh_token", + }, + ) + revoked.raise_for_status() + except (httpx.HTTPError, ValueError) as exc: + raise AppleServiceError("Apple authorization revocation failed") from exc + finally: + if owns_client: + await client.aclose() diff --git a/backend/tests/test_apple_deletion.py b/backend/tests/test_apple_deletion.py new file mode 100644 index 0000000..89df8e3 --- /dev/null +++ b/backend/tests/test_apple_deletion.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from urllib.parse import parse_qs + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from sqlalchemy import select + +from app.config import Settings +from app.models import AppleLoginChallenge, User +from app.services.apple_auth import AppleIdentity, AppleServiceError +from app.services.apple_oauth import apple_client_secret, revoke_apple_authorization +from tests.conftest import auth, create_user + + +def test_client_secret_is_short_lived_and_signed(tmp_path): + key = ec.generate_private_key(ec.SECP256R1()) + path = tmp_path / "apple.p8" + path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + settings = Settings(apple_team_id="TEAM", apple_key_id="KEY", apple_private_key_path=path) + token = apple_client_secret(settings) + claims = jwt.decode( + token, key.public_key(), algorithms=["ES256"], audience="https://appleid.apple.com" + ) + assert claims["iss"] == "TEAM" + assert claims["sub"] == settings.apple_client_id + assert claims["exp"] - claims["iat"] == 300 + assert jwt.get_unverified_header(token)["kid"] == "KEY" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("wrong_subject", [False, True]) +async def test_exchange_binds_identity_before_revoke(wrong_subject): + requests = [] + + def handle(request): + requests.append(request) + if request.url.path == "/auth/token": + return httpx.Response(200, json={"id_token": "identity", "refresh_token": "refresh"}) + return httpx.Response(200) + + class Verifier: + async def verify(self, token): + assert token == "identity" + return AppleIdentity(subject="other" if wrong_subject else "owner", nonce="nonce") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: + kwargs = dict( + code="code", subject="owner", nonce="nonce", client_secret="secret", client=client + ) + if wrong_subject: + with pytest.raises(AppleServiceError): + await revoke_apple_authorization(Settings(), Verifier(), **kwargs) + assert len(requests) == 1 + else: + await revoke_apple_authorization(Settings(), Verifier(), **kwargs) + assert parse_qs(requests[0].content.decode())["grant_type"] == ["authorization_code"] + assert parse_qs(requests[1].content.decode())["token"] == ["refresh"] + + +@pytest.mark.parametrize( + "case", + [ + "success", + "wrong_user", + "wrong_nonce", + "revoke_failure", + "missing_body", + "expired", + "consumed", + "missing_config", + ], +) +def test_apple_deletion_requires_matching_reauthentication(client, monkeypatch, case): + token, user = create_user(client, "01092223333", "CHILD", "테스트") + container = client.app.state.container + + async def seed(): + async with container.database.sessions() as session: + stored = await session.get(User, user["id"]) + stored.apple_subject = "owner" + await session.commit() + + client.portal.call(seed) + challenge = client.post("/v1/auth/apple/challenge").json() + + async def invalidate(): + async with container.database.sessions() as session: + stored = await session.get(AppleLoginChallenge, challenge["challengeId"]) + if case == "expired": + stored.expires_at = datetime.now(UTC) - timedelta(seconds=1) + else: + stored.consumed_at = datetime.now(UTC) + await session.commit() + + if case in {"expired", "consumed"}: + client.portal.call(invalidate) + + async def verify(token): + return AppleIdentity( + subject="other" if case == "wrong_user" else "owner", + nonce="other" if case == "wrong_nonce" else challenge["nonce"], + ) + + revoked = [] + + async def revoke(*args, **kwargs): + revoked.append(kwargs) + if case == "revoke_failure": + raise AppleServiceError("unavailable") + + monkeypatch.setattr(container.apple_identity, "verify", verify) + + def secret(settings): + if case == "missing_config": + raise AppleServiceError("unconfigured") + return "secret" + + monkeypatch.setattr("app.account_router.apple_client_secret", secret) + monkeypatch.setattr("app.account_router.revoke_apple_authorization", revoke) + body = { + "challengeId": challenge["challengeId"], + "identityToken": "identity", + "authorizationCode": "code", + } + response = client.request( + "DELETE", "/v1/account", headers=auth(token), json=None if case == "missing_body" else body + ) + expected = { + "success": 204, + "wrong_user": 401, + "wrong_nonce": 401, + "revoke_failure": 503, + "missing_body": 400, + "expired": 401, + "consumed": 401, + "missing_config": 503, + } + assert response.status_code == expected[case], response.text + assert bool(revoked) == (case in {"success", "revoke_failure"}) + + async def exists(): + async with container.database.sessions() as session: + return await session.scalar(select(User.id).where(User.id == user["id"])) + + assert bool(client.portal.call(exists)) == (case != "success") From 1807e4ce4f96dd8f794752eb9dad47ed966050d3 Mon Sep 17 00:00:00 2001 From: Dohyeop Lim Date: Sun, 13 Sep 2026 21:24:19 +0900 Subject: [PATCH 3/3] chore: configure Apple signing and AI privacy settings --- backend/.env.example | 5 +++++ backend/deploy/local.env.example | 3 +++ backend/deploy/production.env.example | 5 +++++ backend/docker-compose.local.yml | 11 +++++++++++ backend/docker-compose.yml | 11 +++++++++++ backend/docs/aws-cicd.md | 6 ++++++ backend/scripts/check_stack.py | 1 + 7 files changed, 42 insertions(+) diff --git a/backend/.env.example b/backend/.env.example index 45de798..cecc297 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,9 @@ MOCK_EXTERNAL_SERVICES=false SMS_PROVIDER=solapi APPLE_LOGIN_ENABLED=true APPLE_CLIENT_ID=com.dohyeoplim.collog-ios +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY_PATH= SOLAPI_API_KEY= SOLAPI_API_SECRET= SOLAPI_SENDER= @@ -43,6 +46,8 @@ DEEPGRAM_MODEL=nova-3 DEEPGRAM_LANGUAGE=ko GEMINI_API_KEY= +GEMINI_DATA_PROCESSING_APPROVED=false +ELEVENLABS_DATA_PROCESSING_APPROVED=false GEMINI_MODEL=gemini-3.6-flash # Thinking tokens share this budget; 500 is too small for recent Flash models. GEMINI_MAX_OUTPUT_TOKENS=2048 diff --git a/backend/deploy/local.env.example b/backend/deploy/local.env.example index 15d8a19..fc3439a 100644 --- a/backend/deploy/local.env.example +++ b/backend/deploy/local.env.example @@ -8,3 +8,6 @@ S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= # Absolute host path to the existing APNs key. APNS_KEY_FILE= +APPLE_TEAM_ID=3X4434LYHD +APPLE_KEY_ID= +APPLE_KEY_FILE= diff --git a/backend/deploy/production.env.example b/backend/deploy/production.env.example index 7d50514..eac04aa 100644 --- a/backend/deploy/production.env.example +++ b/backend/deploy/production.env.example @@ -20,8 +20,13 @@ SOLAPI_API_SECRET= SOLAPI_SENDER= APPLE_LOGIN_ENABLED=true APPLE_CLIENT_ID=com.dohyeoplim.collog-ios +APPLE_TEAM_ID=3X4434LYHD +APPLE_KEY_ID= +APPLE_KEY_FILE= DEEPGRAM_API_KEY= GEMINI_API_KEY= +GEMINI_DATA_PROCESSING_APPROVED=false +ELEVENLABS_DATA_PROCESSING_APPROVED=false # Absolute host path. The container reads /run/secrets/apns.p8. APNS_KEY_FILE= diff --git a/backend/docker-compose.local.yml b/backend/docker-compose.local.yml index 9b2b51a..ee44e66 100644 --- a/backend/docker-compose.local.yml +++ b/backend/docker-compose.local.yml @@ -192,6 +192,9 @@ services: SOLAPI_SENDER: ${SOLAPI_SENDER:-} APPLE_LOGIN_ENABLED: "true" APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.dohyeoplim.collog-ios} + APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} + APPLE_KEY_ID: ${APPLE_KEY_ID:-} + APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:-sandbox} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} @@ -202,6 +205,8 @@ services: DEEPGRAM_MODEL: ${DEEPGRAM_MODEL:-nova-3} DEEPGRAM_LANGUAGE: ko GEMINI_API_KEY: ${GEMINI_API_KEY:?Set GEMINI_API_KEY} + GEMINI_DATA_PROCESSING_APPROVED: ${GEMINI_DATA_PROCESSING_APPROVED:-false} + ELEVENLABS_DATA_PROCESSING_APPROVED: ${ELEVENLABS_DATA_PROCESSING_APPROVED:-false} GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.6-flash} QUESTION_TTS_PROVIDER: ${QUESTION_TTS_PROVIDER:-elevenlabs_direct} ELEVENLABS_API_KEY: ${ELEVENLABS_API_KEY:?Set ELEVENLABS_API_KEY} @@ -217,6 +222,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: ${APPLE_KEY_FILE:?Set APPLE_KEY_FILE} + target: /run/secrets/apple-signin.p8 + read_only: true + bind: + create_host_path: false healthcheck: test: - CMD diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e1275dd..c97f5bc 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -27,6 +27,9 @@ x-backend-environment: &backend-environment SOLAPI_SENDER: ${SOLAPI_SENDER:-} APPLE_LOGIN_ENABLED: ${APPLE_LOGIN_ENABLED:-true} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.dohyeoplim.collog-ios} + APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} + APPLE_KEY_ID: ${APPLE_KEY_ID:-} + APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:?Set APNS_ENVIRONMENT} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} @@ -37,6 +40,8 @@ x-backend-environment: &backend-environment DEEPGRAM_MODEL: ${DEEPGRAM_MODEL:-nova-3} DEEPGRAM_LANGUAGE: ko GEMINI_API_KEY: ${GEMINI_API_KEY:?Set GEMINI_API_KEY} + GEMINI_DATA_PROCESSING_APPROVED: ${GEMINI_DATA_PROCESSING_APPROVED:-false} + ELEVENLABS_DATA_PROCESSING_APPROVED: ${ELEVENLABS_DATA_PROCESSING_APPROVED:-false} GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.6-flash} QUESTION_TTS_PROVIDER: ${QUESTION_TTS_PROVIDER:-ios_local} ELEVENLABS_API_KEY: ${ELEVENLABS_API_KEY:-} @@ -162,6 +167,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: ${APPLE_KEY_FILE:?Set APPLE_KEY_FILE} + target: /run/secrets/apple-signin.p8 + read_only: true + bind: + create_host_path: false healthcheck: test: - CMD diff --git a/backend/docs/aws-cicd.md b/backend/docs/aws-cicd.md index 4c20730..87c4287 100644 --- a/backend/docs/aws-cicd.md +++ b/backend/docs/aws-cicd.md @@ -60,6 +60,12 @@ QUESTION_TTS_PROVIDER=elevenlabs_direct Leave S3 access and secret keys empty in instance-role mode. Set the remaining provider credentials, independent DB/JWT/LiveKit secrets and `APNS_KEY_FILE`. The APNs key must be readable by container UID `10001`. +Set `APPLE_TEAM_ID`, `APPLE_KEY_ID` and `APPLE_KEY_FILE` for a Sign in with Apple key. +It is mounted read-only at `/run/secrets/apple-signin.p8` and must also be readable by UID `10001`. +Account deletion reauthorizes the user and revokes Apple's token before deleting the account. +Set `GEMINI_DATA_PROCESSING_APPROVED=true` only after verifying paid API data-processing terms. +Set `ELEVENLABS_DATA_PROCESSING_APPROVED=true` only after verifying its data-use settings. +Until approved, calls remain available without analysis and question speech uses the device voice. Keep secrets on EC2. Do not put them in the repository, build arguments or workflow logs. ## Deployment diff --git a/backend/scripts/check_stack.py b/backend/scripts/check_stack.py index 3aeb754..e8ce76a 100644 --- a/backend/scripts/check_stack.py +++ b/backend/scripts/check_stack.py @@ -81,6 +81,7 @@ def synthetic_environment(key: Path) -> dict[str, str]: LIVEKIT_DOMAIN="rtc.example.invalid", ACME_EMAIL="test@example.invalid", APNS_KEY_FILE=str(key), + APPLE_KEY_FILE=str(key), APNS_ENVIRONMENT="sandbox", APNS_TEAM_ID="TESTTEAM00", APNS_KEY_ID="TESTKEY000",