rules_version = '2'; // FANUZU — Firestore 보안 규칙 (2주차 설계본 v0.1) // // 근거 // - wireframe/handoff/rbac-summary.md 역할 3종 · 운영자 등급 3단계(D-18) // - Docs/별첨1_기능별_완료기준표.html 「전 기능 공통 완료 기준 — 서버검증 원칙」 // - 주간보고/1주차/src/1주차_정책설계서_초안.md P-02 원장 무결성 · P-06 어뷰징 방어 // // 3대 원칙 // 1) POP · FPI · 등급 · 원장 필드는 클라이언트가 쓸 수 없다. // 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다. // → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장. // 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다. // 3) 운영자 권한은 커스텀 클레임 role(master|ops|viewer)로만 판정한다. // Firestore 문서 조회로 판정하지 않는다 — 판정 1회당 읽기 1회 비용이 붙고, // 규칙 평가 중 문서 읽기는 우회 표면을 늘린다. // // 미구현 표기 // - 규칙은 거부만 할 수 있고 로그를 남기지 못한다. rbac-summary 「관리자 권한 없는 접근은 // E-PERM-01로 차단하고 시도 자체를 작업 로그에 남긴다」의 로그 적재는 Admin 콘솔이 // Cloud Functions를 경유하도록 설계해 Functions 층에서 처리한다(설계서 §8 fn-admin-*). service cloud.firestore { match /databases/{database}/documents { // ───────────────────────── 공통 헬퍼 ───────────────────────── function signedIn() { return request.auth != null; } function isSelf(u) { return signedIn() && request.auth.uid == u; } function role() { return signedIn() ? request.auth.token.get('role', '') : ''; } function isViewer() { return role() == 'master' || role() == 'ops' || role() == 'viewer'; } function isOps() { return role() == 'master' || role() == 'ops'; } function isMaster() { return role() == 'master'; } // 업데이트가 지정한 필드만 건드렸는지 검사 — 그 외 필드 위조를 차단한다. function touchesOnly(fields) { return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields); } // 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단. function authorIsSelf() { return signedIn() && request.resource.data.authorUid == request.auth.uid; } // 요청자의 users 문서. 비정규화 복제 필드(표시명·팬덤)의 원본 대조에 쓴다. // §7.1 원칙 3은 "역할 판정"에 get()을 쓰지 않는다는 뜻이고, 여기는 역할 판정이 아니다. // 호출 지점은 전부 생성(create) 1건 단위라 목록 조회처럼 읽기가 곱해지지 않는다. // 같은 평가 안에서 같은 경로의 get()은 1회로 합산된다. function myUser() { return get(/databases/$(database)/documents/users/$(request.auth.uid)).data; } // 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단 (교차검증 F-05) function authorNameMatchesProfile() { return request.resource.data.authorName == myUser().displayName; } // 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단 (교차검증 F-05) function fandomIsMine() { return request.resource.data.fandomId == myUser().fandomId; } // 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08) function campaignIsPublic() { return resource.data.status in ['voting', 'funding', 'success', 'fail', 'report']; } // ───────────────────────── 1. 운영 파라미터 ───────────────────────── // 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다. // 근거: 결정 D-07·D-13·D-14·D-21·D-22·D-23 · FPI 가중치(P-03) · 이클립스 수치(CD-91) // 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE match /config/{configId} { allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기 allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용 } // 어뷰징 탐지 임계치·판정 로직. 노출되면 우회가 가능해 클라이언트 접근을 전면 차단한다. // 근거: 정책설계서 P-06 「규칙은 공개하지 않는 것을 제안」 match /configPrivate/{configId} { allow read, write: if false; // 서버(Functions) 전용 } // 앱 버전 정책 · 점검 모드 · 최소 지원 버전. 미로그인 스플래시가 읽는다. // 화면: S-A-SPLASH · S-X-MAINT · S-AD-SYSTEM match /appConfig/{docId} { allow read: if true; allow write: if isMaster(); // rbac-summary 「점검 모드·강제 업데이트」 = 마스터 전용 } // 다국어 문자열 원격 배포 리소스. 화면: S-M-LANG · S-AD-I18N match /i18n/{locale} { allow read: if true; allow write: if isMaster(); // rbac-summary 「다국어 문자열」 = 마스터 전용 } // ───────────────────────── 2. 사용자 ───────────────────────── // 계정·프로필. 등급(grade)·누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다. // 화면: S-A-PROFILE · S-M-HOME · S-M-SETTINGS · S-AD-USER match /users/{uid} { allow get: if isSelf(uid) || isViewer(); allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER) // 교차검증 F-06 — 클라이언트 생성 경로를 닫는다. // 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고 // ② 클라이언트가 먼저 만들면 grade·totalContributedPop 이 허용 목록에 없어 필드가 빈 // 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점). // 생성 주체를 Auth 트리거 하나로 고정한다. allow create: if false; // 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다. allow update: if isSelf(uid) && touchesOnly(['displayName','photoUrl','countryCode','locale', 'representativeBadgeId','notificationPrefs','updatedAt']); // 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상 allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']); allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13) } // POP 지갑 요약(집계 필드). 읽기만 허용하고 쓰기는 전부 Functions. // 화면: S-M-WALLET · S-H-HOME · S-C-FUND match /users/{uid}/wallet/{docId} { allow read: if isSelf(uid) || isViewer(); // S-AD-LEDGER 조회 등급 이상 allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」 } // POP Lot — 획득 단위별 잔량·만료일. FIFO 차감의 계산 단위. // 화면: S-M-WALLET(소멸 예정 POP) match /users/{uid}/popLots/{lotId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; // 잔량·만료 조작 차단 } // POP 원장. 지급·차감·환불·소멸 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다. // 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER match /users/{uid}/popTransactions/{txId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; // 별첨1 ② 원장 정합성 · 멱등키는 Functions 관리 } // 활동 로그(걸음·스밍·위치·출석). 어뷰징 판정 입력이라 클라이언트 쓰기 금지. // 화면: S-H-WALK · S-H-STREAM · S-H-PLACE · S-H-ATTEND · S-AD-ABUSE match /users/{uid}/activityLogs/{logId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; } // 퀘스트 진행·일일 한도 카운터. 한도 우회를 막기 위해 서버 전용. // 화면: S-H-HOME · S-X-AD(일일 한도) match /users/{uid}/questProgress/{questId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; } // 보유 배지·등급 이력. 부여는 서버 판정. // 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드) // // 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데, // **1차 범위에 타인 프로필 화면이 없다**(화면 95개 전수 확인). 주석이 사실과 달랐다. // 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고, // 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4). // 랭킹 하나 보여주려고 타인 문서를 통째로 여는 경로를 닫는다. match /users/{uid}/badges/{badgeId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; } // 구독(Focus) 상태. 영수증 검증 결과라 서버 전용. // 화면: S-M-FOCUS match /users/{uid}/subscription/{docId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; } // 알림함. 읽음 처리만 본인이 할 수 있다. // 화면: S-X-NOTICE match /users/{uid}/notifications/{notiId} { allow read: if isSelf(uid); allow update: if isSelf(uid) && touchesOnly(['read','readAt']); allow create, delete: if false; // 발송은 Functions(FCM) 경유 } // 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다. // 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS match /users/{uid}/consents/{consentId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; // 기록은 fn-consent-record 경유 (교차검증 F-04) } // 차단 목록. 본인만 읽고 쓴다. // 화면: S-M-BLOCK · S-I-REPORT match /users/{uid}/blocks/{targetUid} { allow read, write: if isSelf(uid); } // ───────────────────────── 3. 아티스트 · 팬덤 · 행성 ───────────────────────── // 아티스트 마스터. 온보딩에서 미인증 상태로도 목록을 본다. // 화면: S-A-ARTIST · S-W-PREREG-NEW match /artists/{artistId} { allow read: if true; allow write: if isOps(); } // 팬덤. fpiScore·orbit 등 집계 필드가 붙어 있어 클라이언트 쓰기를 전면 금지한다. // 화면: S-G-MAP · S-G-FPI · S-A-ARTIST match /fandoms/{fandomId} { allow read: if true; // 웹 브릿지(미로그인) 요약 노출 — D-16 allow write: if false; // 생성·집계 모두 Functions } // 팬덤 멤버십 + 개인 기여 집계(히어로 랭킹 원천). // 화면: S-M-HERO · S-I-LOUNGE(내 기여도) match /fandoms/{fandomId}/members/{uid} { allow read: if signedIn(); allow write: if false; // 기여 POP 집계 = 서버 산출값 } // 사용자 행성. 성장 단계·누적 기여 POP은 서버 산출이고, 외형 설정만 본인이 바꾼다. // 화면: S-G-PLANET · S-G-THEME · S-A-PLANET-NEW match /fandoms/{fandomId}/planets/{planetId} { allow read: if signedIn(); // 타인 행성 열람 허용(갤럭시 맵 진입) allow update: if isSelf(planetId) && touchesOnly(['planetType','colorKey','themeId','effectLevel','updatedAt']); allow create, delete: if false; // 생성은 온보딩 Function(fn-planet-create) } // 성장 단계 정의(7단계). 마스터 데이터. // 화면: S-G-STAGE match /planetStages/{stageNo} { allow read: if true; allow write: if isMaster(); } // FPI 이벤트(집계 입력). 클라이언트는 읽지도 쓰지도 않는다 — 가중치 역산 방지. // 화면: S-AD-LEDGER(FPI 집계 로그) match /fandoms/{fandomId}/fpiEvents/{eventId} { allow read: if isViewer(); allow write: if false; } // FPI 사전 집계 스냅샷(축별 순위·등락). 랭킹 화면의 실제 읽기 대상. // 화면: S-G-FPI · S-G-CONTRIB match /fandoms/{fandomId}/fpiSnapshots/{periodKey} { allow read: if signedIn(); allow write: if false; } // 이클립스 발동 이벤트. // 화면: S-G-ECLIPSE · S-AD-ECLIPSE match /fandoms/{fandomId}/eclipses/{eclipseId} { allow read: if signedIn(); allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준) } // 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다. // 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다. // 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」 match /fandoms/{fandomId}/eclipses/{eclipseId}/shards/{shardId} { allow read: if signedIn(); // 합산은 클라이언트가 해도 무방 allow write: if false; } // 방어 로그(라이브 소셜 프루프 원천). // 화면: S-G-ECLIPSE(실시간 방어 로그) match /fandoms/{fandomId}/eclipses/{eclipseId}/defenseLogs/{logId} { allow read: if signedIn(); allow write: if false; } // 영웅의 선물 발동 이벤트. // 화면: S-X-HERODROP match /fandoms/{fandomId}/heroDrops/{dropId} { allow read: if signedIn(); allow write: if false; } // 수령 내역. 문서 ID = uid 로 중복 수령을 구조적으로 차단한다(E-POP-03). // 화면: S-X-HERODROP match /fandoms/{fandomId}/heroDrops/{dropId}/claims/{uid} { allow read: if isSelf(uid) || isViewer(); allow write: if false; // 수령은 fn-herodrop-claim 경유 } // ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ───────────────────────── // 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST match /quests/{questId} { allow read: if signedIn(); allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」 } // 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL match /places/{placeId} { allow read: if signedIn(); allow write: if isOps(); } // AI Daily Briefing 카드(Functions 생성 → 캐시). 화면: S-H-BRIEF · S-H-BRIEF-DETAIL match /briefings/{briefingId} { allow read: if true; // 웹 브릿지 열람 — D-16 allow write: if isOps(); // 생성은 Functions, 수동 편집은 운영 } // 배지 도감 마스터. 화면: S-M-BADGE match /badges/{badgeId} { allow read: if true; allow write: if isMaster(); } // ───────────────────────── 5. 캠페인 ───────────────────────── // 캠페인. 목표 달성률·후원 합계는 집계 필드라 클라이언트가 못 쓴다. // 화면: S-C-LIST · S-C-DETAIL · S-C-NEW · S-AD-CAMPAIGN // // ⚠️ 목록 쿼리에는 status 필터가 필수다 (교차검증 G-03). // 아래 read 규칙이 공개 상태만 허용하므로, status 필터가 없는 목록 쿼리는 // draft·review 문서까지 매칭돼 **쿼리 전체가 거부**된다(Firestore 는 list 규칙을 // 반환 후보 전체에 적용한다). 인덱스 문제로 오인하기 쉬운 지점이다. // 허용되는 목록 쿼리 형태는 설계서 §6.1 IDX-15·16·17·19 참조. match /campaigns/{campaignId} { // 교차검증 F-08 — 예전에는 read: if true 라 심사 중(review)·초안(draft) 캠페인 본문이 // URL만 알면 미로그인 상태로 읽혔다. FR-CMP-01「승인 대기 캠페인은 목록에 노출하지 않는다」(E-CMP-03)를 // 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다. allow read: if campaignIsPublic() || isOps() || (signedIn() && resource.data.creatorUid == request.auth.uid); // 교차검증 F-02 — 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로 // 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다. allow create: if signedIn() && request.resource.data.keys().hasOnly([ 'fandomId','creatorUid','creatorName','title','summary','body', 'coverImageUrl','campaignType','targetPop','endsAt','status', 'raisedPop','achievedRate','donorCount','votedPop','isAiDraft', 'refundState','createdAt','updatedAt' ]) && request.resource.data.creatorUid == request.auth.uid && request.resource.data.creatorName == myUser().displayName && fandomIsMine() && request.resource.data.status == 'draft' && request.resource.data.raisedPop == 0 // 개설 시 모금액 0 고정 && request.resource.data.achievedRate == 0 // 달성률 위조 차단(웹 브릿지 노출 대상) && request.resource.data.donorCount == 0 // 후원자 수 위조 차단 && request.resource.data.votedPop == 0 // D-22 승격 임계치 판정 입력 && request.resource.data.isAiDraft == false // 「AI 초안」 배지 사칭 차단 && request.resource.data.refundState == 'none'; // 개설자는 심사 전 초안만 고칠 수 있다. allow update: if signedIn() && resource.data.creatorUid == request.auth.uid && resource.data.status == 'draft' && touchesOnly(['title','summary','body','coverImageUrl', 'targetPop','campaignType','endsAt','updatedAt']); // 승인·반려·상태 전이. rbac-summary 「캠페인 승인·반려」 = 운영 이상 allow update: if isOps() && touchesOnly(['status','reviewNote','rejectReason','reviewedBy','updatedAt']); allow delete: if false; // 원장 연결 문서라 삭제 대신 상태 전이 } // 후원 내역. POP 차감과 1:1이라 전량 서버 경유. // 화면: S-C-FUND · S-C-MINE · S-C-REFUND match /campaigns/{campaignId}/donations/{donationId} { allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL) allow write: if false; // 별첨1 ⑤ 환불 무결성 } // 결과 보고. 성공 캠페인 개설자만 1회 제출하고 이후 운영이 관리한다. // 화면: S-C-RESULT match /campaigns/{campaignId}/reports/{reportId} { allow read: if true; // 교차검증 F-07 — §7.3 표는 「성공 캠페인 개설자」라고 적었는데 규칙은 creatorUid만 보고 // 상태를 확인하지 않아 실패·초안 캠페인에도 결과보고를 올릴 수 있었다. // 별첨1 ⑤ 판정 기준 「결과보고 게시 확인」의 대상은 성공한 캠페인이다. allow create: if signedIn() && get(/databases/$(database)/documents/campaigns/$(campaignId)) .data.creatorUid == request.auth.uid && get(/databases/$(database)/documents/campaigns/$(campaignId)) .data.status == 'success'; allow update: if isOps(); allow delete: if false; } // ───────────────────────── 6. INSIDE 커뮤니티 ───────────────────────── // 라운지 게시물. 좋아요 수는 집계 필드라 본인 수정 목록에서 제외한다. // 화면: S-I-LOUNGE · S-I-POST · S-I-POST-NEW match /posts/{postId} { allow read: if signedIn(); // 교차검증 F-05 — 예전에는 authorUid·likeCount·visibility 3개만 봐서 // ① authorName 을 "FANUZU 운영팀"으로 적어 사칭 ② commentCount 위조 // ③ 소속하지 않은 팬덤 피드에 글 삽입이 가능했다. 화이트리스트 + 원본 대조로 닫는다. allow create: if authorIsSelf() && request.resource.data.keys().hasOnly([ 'fandomId','authorUid','authorName','body','imageUrls', 'likeCount','commentCount','visibility','createdAt','updatedAt' ]) && authorNameMatchesProfile() && fandomIsMine() && request.resource.data.likeCount == 0 && request.resource.data.commentCount == 0 && request.resource.data.visibility == 'visible'; // 교차검증 F-14 — 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다. allow update: if signedIn() && resource.data.authorUid == request.auth.uid && touchesOnly(['body','imageUrls','updatedAt']); // 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상 allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); allow delete: if signedIn() && resource.data.authorUid == request.auth.uid; } // 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다. match /posts/{postId}/likes/{uid} { allow read: if signedIn(); allow create, delete: if isSelf(uid); // 집계 반영은 Functions 트리거 allow update: if false; } // 댓글. 화면: S-I-POST match /posts/{postId}/comments/{commentId} { allow read: if signedIn(); // 교차검증 F-05 — 댓글에도 같은 표시명 사칭 경로가 있었다. allow create: if authorIsSelf() && request.resource.data.keys().hasOnly([ 'authorUid','authorName','body','visibility','createdAt','updatedAt' ]) && authorNameMatchesProfile() && request.resource.data.visibility == 'visible'; allow update: if signedIn() && resource.data.authorUid == request.auth.uid && touchesOnly(['body','updatedAt']); allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); allow delete: if signedIn() && resource.data.authorUid == request.auth.uid; } // 현상금 Q&A 질문. 현상금 예치가 POP 차감이라 생성도 Functions 경유다. // 화면: S-I-KNOW · S-I-QUESTION · S-I-QUESTION-NEW match /questions/{questionId} { allow read: if signedIn(); allow write: if false; // fn-question-create(예치) · fn-answer-accept(전송) } // 답변. 등록은 재화 이동이 없어 클라이언트 쓰기를 허용하되, 채택 필드는 잠근다. // 화면: S-I-QUESTION · S-I-ANSWER-NEW match /questions/{questionId}/answers/{answerId} { allow read: if signedIn(); // 교차검증 F-05 — 답변에도 같은 표시명 사칭 경로가 있었다. allow create: if authorIsSelf() && request.resource.data.keys().hasOnly([ 'authorUid','authorName','body','sourceUrls','accepted', 'visibility','createdAt','updatedAt' ]) && authorNameMatchesProfile() && request.resource.data.accepted == false; allow update: if signedIn() && resource.data.authorUid == request.auth.uid && touchesOnly(['body','sourceUrls','updatedAt']); allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); allow delete: if false; // 채택 대상이라 삭제 대신 숨김 } // 신고. 접수는 본인 명의로만, 처리 상태는 운영만 바꾼다. // 화면: S-I-REPORT · S-AD-REPORT match /reports/{reportId} { allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid); allow list: if isOps(); allow create: if signedIn() && request.resource.data.reporterUid == request.auth.uid && request.resource.data.status == 'pending'; allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']); allow delete: if false; } // 문의. 화면: S-M-INQUIRY · S-AD-REPORT match /inquiries/{inquiryId} { allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid); allow list: if isOps(); allow create: if authorIsSelf() && request.resource.data.status == 'pending'; allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']); allow delete: if false; } // ───────────────────────── 7. STATION · 광고 · 공지 ───────────────────────── // 큐레이션 링크. 화면: S-S-HUB match /stationLinks/{linkId} { allow read: if signedIn(); allow write: if isOps(); } // 사용자 제안 링크. 심사 상태는 운영만 바꾼다. 화면: S-S-SUGGEST match /stationSuggestions/{suggestionId} { allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid); allow list: if isOps(); allow create: if authorIsSelf() && request.resource.data.status == 'pending'; allow update: if isOps() && touchesOnly(['status','reviewNote','updatedAt']); allow delete: if false; } // 광고 보상 로그. 보상 지급 근거라 서버 전용. 화면: S-X-AD · S-AD-ADREV match /adRewardLogs/{logId} { allow read: if isViewer(); // rbac-summary 「광고 보상 내역」 = 조회 이상 allow write: if false; } // 공지·배너. 화면: S-X-NOTICE · S-AD-NOTICE match /notices/{noticeId} { allow read: if true; allow write: if isOps(); } // 푸시 발송 이력·도달률. 운영자만 보고, 발송 집계는 Functions(FCM)가 쓴다. // 화면: S-AD-PUSH · rbac-summary 「푸시 발송 관리」 = 운영 이상 (교차검증 F-04 #2) match /pushCampaigns/{pushId} { allow read: if isViewer(); allow write: if false; // 발송·집계는 fn-push-send 경유 } // ───────────────────────── 8. 사전예약 · 초대 코드 ───────────────────────── // 사전예약 신청. 미로그인 웹앱에서 접수되고 중복·봇 판정이 붙어 서버 전용으로 둔다. // 화면: S-W-PREREG · S-W-PREREG-DONE · S-AD-PREREG // 근거: 정책설계서 P-06 reCAPTCHA v3 · E-PRE-02 중복 차단 · E-PRE-03 보류 match /preRegistrations/{preRegId} { allow read: if isOps(); // 개인정보(연락처) 포함 — 운영 이상만 allow write: if false; // 접수는 fn-prereg-submit 경유 } // 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다. // 남용 방지를 위해 문서에는 사용 횟수 등 최소 필드만 두고 개인정보를 담지 않는다. // 화면: S-W-PREREG(코드 입력) · E-PRE-01 match /inviteCodes/{code} { allow get: if true; // 코드 직접 조회만 허용 allow list: if isOps(); // 전수 열거는 운영 이상(코드 수집 방지) allow write: if false; } // 신규 행성 개설 신청. 화면: S-W-PREREG-NEW · S-AD-PREREG match /planetApplications/{applicationId} { allow read: if isOps(); allow write: if false; // 접수·심사 모두 Functions } // ───────────────────────── 9. 참여형 번역 ───────────────────────── // 번역 제출. 보상이 걸려 있어 제출은 Functions 경유(도배 제한·중복 검사). // 화면: S-M-TRANSLATE · S-AD-TRANSLATE match /translationEntries/{entryId} { allow read: if signedIn(); allow write: if false; } // 마킹. 문서 ID = uid 로 중복 마킹을 차단하고, 본인 번역 자기 마킹은 서버가 막는다(E-TRN-01). match /translationEntries/{entryId}/markings/{uid} { allow read: if signedIn(); allow create: if isSelf(uid) && get(/databases/$(database)/documents/translationEntries/$(entryId)) .data.authorUid != request.auth.uid; allow update, delete: if false; // 번복 불가 — 어뷰징 가드 } // ───────────────────────── 10. 랭킹 사전 집계 ───────────────────────── // 글로벌·국가·아티스트 랭킹 사전 집계 문서. 랭킹 화면은 이 문서 1건만 읽는다. // 화면: S-G-FPI · S-I-RANK · S-M-HERO match /rankings/{rankingId} { allow read: if signedIn(); allow write: if false; } // ───────────────────────── 11. 관리자 ───────────────────────── // 관리자 계정 목록. 실제 권한 판정은 커스텀 클레임이고 이 문서는 목록·감사용이다. // 화면: S-AD-ACCOUNT match /admins/{uid} { allow read: if isViewer(); allow write: if false; // 클레임 설정과 함께 fn-admin-set-role 경유 } // 관리자 작업 로그. 전건 기록이 판정 기준이라 수정·삭제를 규칙 층에서 봉인한다. // 화면: S-AD-LOG · 근거: 별첨1 ⑨ 판정 기준 「관리자 조작 전건이 작업 로그로 추적 가능」 match /adminActionLogs/{logId} { allow read: if isViewer(); allow write: if false; } // ─────────────── 12. 컬렉션 그룹 쿼리 (교차검증 F-01) ─────────────── // // Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은 // **단일 컬렉션 쿼리에만** 적용되고 collectionGroup('popTransactions') 에는 적용되지 않는다. // 재귀 와일드카드 블록이 없으면 §1.4가 지정한 컬렉션 그룹 쿼리가 전부 아래 기본 거부에 걸린다. // 실측으로 S-AD-LEDGER·S-C-MINE 등 7건이 403이었다. // // 원칙: **권한을 넓히지 않는다.** 각 블록의 조건은 위 단일 컬렉션 블록과 동일하게 유지한다. // 쓰기는 선언하지 않으므로 그대로 거부된다(원장 append-only 유지). // // 주의: 규칙은 OR로 평가되므로 이 블록들이 기존 단일 컬렉션 읽기를 좁히지 않는다. // S-AD-LEDGER 전체 원장 (IDX-08·09). 조회 등급 이상만. // 본인 내역은 /users/{uid}/popTransactions 단일 컬렉션 경로를 쓰므로 여기서 열지 않는다. match /{path=**}/popTransactions/{txId} { allow read: if isViewer(); } // S-AD-ABUSE 어뷰징 모니터링 (IDX-13). 운영자 전용. match /{path=**}/activityLogs/{logId} { allow read: if isViewer(); } // S-AD-ABUSE 일일 한도 감사 (IDX-14). 운영자 전용. match /{path=**}/questProgress/{questId} { allow read: if isViewer(); } // S-AD-CONTENT 댓글 전수 조회 (IDX-05). 운영자 전용. match /{path=**}/comments/{commentId} { allow read: if isViewer(); } // S-C-MINE·S-C-REFUND 내 후원 내역 (IDX-20). // 단일 컬렉션 규칙과 동일하게 로그인 사용자 읽기(후원자 리더보드가 이미 공개 범위). match /{path=**}/donations/{donationId} { allow read: if signedIn(); } // S-M-HOME 내 답변 (IDX-26). 단일 컬렉션 규칙과 동일. match /{path=**}/answers/{answerId} { allow read: if signedIn(); } // popLots(IDX-11)·환불 배치 donations(IDX-21)는 스케줄 Function 전용이다. // Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다. // ───────────────────────── 13. 기본 거부 ───────────────────────── // 위에서 명시하지 않은 모든 경로는 거부한다. match /{document=**} { allow read, write: if false; } } }