Firestore · Storage 보안 규칙 · 인덱스 · 보안 규칙 테스트 · 개발 서버 구성 스크립트
This commit is contained in:
+397
-61
@@ -36,6 +36,28 @@ service cloud.firestore {
|
||||
function touchesOnly(fields) {
|
||||
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
|
||||
}
|
||||
// 닉네임 길이 — 정책 원장 B-52 (2~20자 · 중복 허용). 검증 기준이
|
||||
// 「20자 성공 · 21자 거부」이므로 길이를 규칙에서 본다. 앱도 같은 두 수를
|
||||
// `nicknameMinLength`·`nicknameMaxLength` 로 들고 있다.
|
||||
// 닉네임을 건드리지 않는 갱신(사진·언어 등)은 이 검사를 통과시킨다.
|
||||
// 예약어 판정용 정규화 — 폭 없는 문자(U+200B~200D · U+FEFF)를 지우고 전각 공백(U+3000)을
|
||||
// 공백으로 본 뒤 앞뒤 공백을 떼고 소문자로. 앱 `normalizeNickname` 과 같은 계산.
|
||||
function nicknameKey(n) {
|
||||
return n.replace('[\\x{200B}-\\x{200D}\\x{FEFF}]', '').replace('\\x{3000}', ' ').trim().lower();
|
||||
}
|
||||
function nicknameOk() {
|
||||
return !('nickname' in request.resource.data.diff(resource.data).affectedKeys())
|
||||
|| (request.resource.data.nickname is string
|
||||
&& request.resource.data.nickname.size() >= 2
|
||||
&& request.resource.data.nickname.size() <= 20
|
||||
// 운영 사칭 이름 차단 — 대소문자 무시 부분 일치. 앱 `reservedNicknameWords` 와 같은 목록.
|
||||
&& !nicknameKey(request.resource.data.nickname)
|
||||
.matches('.*(fanuzu|운영팀|관리자|admin|official).*')
|
||||
// 닉네임 없는 작성자 표기(`publicNameOf` 「팬」)와 그 번역(Fan · ファン · 粉丝)은
|
||||
// 정확히 일치하면 막는다. 앱 `reservedFanNames` 와 같은 목록.
|
||||
&& !(nicknameKey(request.resource.data.nickname) in ['팬', 'fan', 'ファン', '粉丝']));
|
||||
}
|
||||
|
||||
// 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단.
|
||||
function authorIsSelf() {
|
||||
return signedIn() && request.resource.data.authorUid == request.auth.uid;
|
||||
@@ -48,24 +70,107 @@ service cloud.firestore {
|
||||
function myUser() {
|
||||
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
||||
}
|
||||
// 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단
|
||||
function authorNameMatchesProfile() {
|
||||
return request.resource.data.authorName == myUser().displayName;
|
||||
// 다른 사용자에게 보이는 작성자 이름 — 앱 `publicName(nickname)` 과 같은 계산.
|
||||
// 닉네임이 없거나 이메일 형태면 「팬」. 공급자 실명(displayName)은 쓰지 않는다(M-7).
|
||||
function publicNameOf(u) {
|
||||
let n = u.get('nickname', '');
|
||||
return (n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*')) ? n.trim() : '팬';
|
||||
}
|
||||
// 공개 닉네임이 있는가 — publicNameOf 가 「팬」으로 대체하지 않는 경우와 같다.
|
||||
function hasPublicNickname() {
|
||||
let n = myUser().get('nickname', '');
|
||||
return n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*');
|
||||
}
|
||||
// 라운지 글 · 댓글 · 인사이드 답변의 작성자 이름이 공개 닉네임과 같은지 — 사칭 차단.
|
||||
function authorNameIsPublic() {
|
||||
return request.resource.data.authorName == publicNameOf(myUser());
|
||||
}
|
||||
// 팬덤 회원인가. 멤버십 문서(`fandoms/{id}/members/{uid}`)가 있거나, 없던 시절의
|
||||
// 원천인 대표 팬덤(`users.fandomId`)이 같으면 회원이다. 두 원천을 모두 받아
|
||||
// 멤버십 도입 전후 어느 쪽에서도 같은 판정이 난다.
|
||||
function isFandomMember(fandomId) {
|
||||
return signedIn()
|
||||
&& (exists(/databases/$(database)/documents/fandoms/$(fandomId)/members/$(request.auth.uid))
|
||||
|| myUser().fandomId == fandomId);
|
||||
}
|
||||
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단
|
||||
function fandomIsMine() {
|
||||
return request.resource.data.fandomId == myUser().fandomId;
|
||||
return isFandomMember(request.resource.data.fandomId);
|
||||
}
|
||||
// 라운지 반응(좋아요 · 댓글)을 남길 수 있는 글인가 — 보이는 글이고, 요청자가
|
||||
// 그 글의 팬덤 회원이다. 글 작성과 같은 회원 판정을 쓴다.
|
||||
function canReactTo(postId) {
|
||||
let post = get(/databases/$(database)/documents/posts/$(postId)).data;
|
||||
return post.visibility == 'visible' && isFandomMember(post.fandomId);
|
||||
}
|
||||
// 라운지 본문 — 빈 글 차단 · 길이 상한(글 5,000 · 댓글 1,000).
|
||||
function bodyOk(max) {
|
||||
return request.resource.data.body is string
|
||||
&& request.resource.data.body.size() > 0
|
||||
&& request.resource.data.body.size() <= max;
|
||||
}
|
||||
// 서버 시각 강제 — 앱은 serverTimestamp 를 보낸다. 시각 위조로 정렬 맨 위를 차지하지 못한다.
|
||||
function createdNow() {
|
||||
return request.resource.data.createdAt == request.time;
|
||||
}
|
||||
function updatedNow() {
|
||||
return request.resource.data.updatedAt == request.time;
|
||||
}
|
||||
// 라운지 글 사진 — Storage 경로를 담는다(`users/{uid}/lounge/{postId}-{i}`).
|
||||
// URL 이 아니라 경로라 버킷 이름에 묶이지 않고, 칸마다 정확한 경로만 받으므로
|
||||
// 남의 파일 · 외부 주소 · 다른 글의 사진을 끼워 넣지 못한다. B-44 「이미지 5 장」.
|
||||
function loungeImagesOk(postId) {
|
||||
let urls = request.resource.data.imageUrls;
|
||||
let base = 'users/' + request.auth.uid + '/lounge/' + postId + '-';
|
||||
return urls is list && urls.size() <= 5
|
||||
&& (urls.size() < 1 || urls[0] == base + '0')
|
||||
&& (urls.size() < 2 || urls[1] == base + '1')
|
||||
&& (urls.size() < 3 || urls[2] == base + '2')
|
||||
&& (urls.size() < 4 || urls[3] == base + '3')
|
||||
&& (urls.size() < 5 || urls[4] == base + '4');
|
||||
}
|
||||
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외
|
||||
function campaignIsPublic() {
|
||||
return resource.data.status in ['funding', 'success', 'fail', 'report'];
|
||||
}
|
||||
// 하위 문서(댓글 · 좋아요) 쓰기에서 부모 캠페인이 공개 상태인가.
|
||||
function campaignOpenFor(campaignId) {
|
||||
return get(/databases/$(database)/documents/campaigns/$(campaignId)).data.status
|
||||
in ['funding', 'success', 'fail', 'report'];
|
||||
}
|
||||
// 응원 게시판 댓글 작성 자격 — config/policy.campaignCommentScope(1. 운영 파라미터 참고).
|
||||
function campaignCommentScope() {
|
||||
let policy = /databases/$(database)/documents/config/policy;
|
||||
return exists(policy) ? get(policy).data.get('campaignCommentScope', 'all') : 'all';
|
||||
}
|
||||
function campaignCommenter(campaignId) {
|
||||
let scope = campaignCommentScope();
|
||||
return scope == 'all'
|
||||
|| (scope == 'members'
|
||||
&& isFandomMember(get(/databases/$(database)/documents/campaigns/$(campaignId)).data.fandomId))
|
||||
|| (scope == 'backers'
|
||||
&& exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid)));
|
||||
}
|
||||
|
||||
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
|
||||
|
||||
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
|
||||
// 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
|
||||
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
|
||||
//
|
||||
// config/policy.campaignCommentScope — 응원 게시판 댓글 작성 자격(발주사 확인 중).
|
||||
// 'all'(없으면 이 값) 로그인 회원 누구나 · 'members' 캠페인 팬덤 회원 · 'backers' 후원자.
|
||||
// 명세에 작성 자격이 없어 잠정 'all'. 그 밖의 값은 아무도 쓰지 못한다(오타는 닫힌 쪽으로).
|
||||
//
|
||||
// config/features — 앱 기능 스위치. 지금 있는 키는 하나다.
|
||||
// stationEnabled (bool, 기본 false) STATION 하단 탭 노출.
|
||||
// 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
|
||||
// Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.*
|
||||
// 앱은 로그인 전에도 이 문서를 읽으므로 아래 공개 읽기가 그대로 필요하다.
|
||||
// 쓰기가 마스터 전용인 것이 이 스위치의 유일한 보호막이다.
|
||||
//
|
||||
// 이메일/비밀번호 provider 에 의존하는 규칙은 없다(별첨 B v1.4 로 이메일 가입 제외).
|
||||
// 판정은 전부 request.auth.uid 와 커스텀 클레임 role 로만 한다.
|
||||
match /config/{configId} {
|
||||
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
|
||||
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
|
||||
@@ -104,9 +209,27 @@ service cloud.firestore {
|
||||
// 생성 주체를 Auth 트리거 하나로 고정한다.
|
||||
allow create: if false;
|
||||
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
|
||||
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(원장 B-52 —
|
||||
// 2~20자 · 중복 허용 · 유니크 인덱스 제거). 목록에 없으면 MY 의 닉네임 변경이
|
||||
// permission-denied 로 떨어지는데, 원인이 화면처럼 보여 찾기 어렵다.
|
||||
// `displayName` 은 Auth 트리거가 처음 채우는 필드라(§8.5 fn-auth-oncreate) 함께 남긴다.
|
||||
//
|
||||
// `fcmTokens` — 단말 푸시 토큰. 재화가 아니고 본인 문서에만 붙으므로 본인이
|
||||
// 직접 쓴다. 토큰 등록을 Functions 로 돌리면 로그인마다 호출이 한 번 늘고,
|
||||
// 토큰은 앱이 재발급받을 때마다 바뀌어 그 호출이 계속 반복된다.
|
||||
//
|
||||
// `lastSeenAt` — HUD 「LIVE」 접속자 집계 입력(해석 확인 요청 Q-GM-03). 서버 시각만 받고,
|
||||
// 직전 기록에서 60초가 지나야 다시 쓸 수 있다(앱 주기 2분 · 쓰기 폭주 차단).
|
||||
// `fandomIds`(가입 팬덤 비정규화)는 목록에 없으므로 서버만 쓴다.
|
||||
allow update: if isSelf(uid)
|
||||
&& touchesOnly(['displayName','photoUrl','countryCode','locale',
|
||||
'representativeBadgeId','notificationPrefs','updatedAt']);
|
||||
&& touchesOnly(['nickname','displayName','photoUrl','countryCode','locale',
|
||||
'representativeBadgeId','notificationPrefs',
|
||||
'fcmTokens','lastSeenAt','updatedAt'])
|
||||
&& nicknameOk()
|
||||
&& (!('lastSeenAt' in request.resource.data.diff(resource.data).affectedKeys())
|
||||
|| (request.resource.data.lastSeenAt == request.time
|
||||
&& (!('lastSeenAt' in resource.data)
|
||||
|| request.time > resource.data.lastSeenAt + duration.value(60, 's'))));
|
||||
// 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상
|
||||
allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']);
|
||||
allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13)
|
||||
@@ -167,6 +290,23 @@ service cloud.firestore {
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 결제 기록. `fn-iap-verify` 가 쓰고 환불 회수는 `fn-iap-refund-webhook` 가 바꾼다.
|
||||
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (원장 B-02 · B-72)
|
||||
// 본인이 할 수 있는 것은 **지급 완료 → 환불 요청** 한 가지 전이뿐이다. 금액·상태의
|
||||
// 다른 전이(승인·반려·회수)는 Admin 과 서버가 한다.
|
||||
match /users/{uid}/purchases/{purchaseId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow update: if isSelf(uid)
|
||||
&& resource.data.state == 'granted'
|
||||
&& request.resource.data.state == 'refund-requested'
|
||||
&& request.resource.data.refundReason is string
|
||||
&& request.resource.data.refundReason.size() > 0
|
||||
&& request.resource.data.refundReason.size() <= 100
|
||||
&& request.resource.data.refundRequestedAt == request.time
|
||||
&& touchesOnly(['state', 'refundReason', 'refundRequestedAt']);
|
||||
allow create, delete: if false;
|
||||
}
|
||||
|
||||
// 알림함. 읽음 처리만 본인이 할 수 있다.
|
||||
// 화면: S-X-NOTICE
|
||||
match /users/{uid}/notifications/{notiId} {
|
||||
@@ -175,6 +315,13 @@ service cloud.firestore {
|
||||
allow create, delete: if false; // 발송은 Functions(FCM) 경유
|
||||
}
|
||||
|
||||
// 가입한 행성/성운 목록(명세 §2.58 「가입 또는 생성한 행성/성운」). 가입 · 탈퇴 Function 만 쓴다 —
|
||||
// 클라이언트가 쓰면 가입하지 않은 팬덤이 목록에 끼고 `memberCount` 와 어긋난다.
|
||||
match /users/{uid}/memberships/{fandomId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다.
|
||||
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
|
||||
match /users/{uid}/consents/{consentId} {
|
||||
@@ -215,9 +362,19 @@ service cloud.firestore {
|
||||
// 화면: 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)
|
||||
// 원장 v1.2 C-50 — 행성 외형은 **생성 시 형태(planetType) + 컬러(colorKey)만** 고르고
|
||||
// 이후 사용자 커스텀은 없다. 생성은 온보딩 Function(fn-planet-create)이 하며,
|
||||
// themeId · effectLevel 은 서버 산출값이다. 클라이언트 쓰기는 전부 닫는다.
|
||||
allow create, update, delete: if false;
|
||||
}
|
||||
|
||||
// 갤럭시 맵 상단에 흐르는 최근 사건(이클립스 · 아이템 사용).
|
||||
// 로그인한 사용자에게만 보인다 — 맵 자체가 로그인 뒤 화면이다.
|
||||
// 쓰기는 서버(Functions)만 한다. 클라이언트가 사건을 만들면 맵이 조작된다.
|
||||
// 화면: S-G-GALAXY
|
||||
match /galaxyEvents/{eventId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 성장 단계 정의(7단계). 마스터 데이터.
|
||||
@@ -241,24 +398,43 @@ service cloud.firestore {
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 이클립스 발동 이벤트.
|
||||
// 이클립스 회차(설계서 v1.2 §2.11 `eclipseRounds` — v0.3.0 `eclipses` 에서 전환).
|
||||
// 화면: S-G-ECLIPSE · S-AD-ECLIPSE
|
||||
match /fandoms/{fandomId}/eclipses/{eclipseId} {
|
||||
match /fandoms/{fandomId}/eclipseRounds/{eclipseId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준)
|
||||
}
|
||||
|
||||
// 참여자별 유효 Shake 누적(`participants.validShakes` — v0.3.0 `defenders`). 사용자당
|
||||
// 상한과 속도 제한의 근거이며, 문서 ID 가 uid 라 중복이 구조적으로 없다(G-04).
|
||||
// 판정은 서버가 하므로 클라이언트 쓰기를 막는다. 화면: S-G-ECLIPSE(내 참여량)
|
||||
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/participants/{uid} {
|
||||
allow read: if signedIn(); // 참여자 목록은 공동 이벤트의 일부다
|
||||
allow write: if false; // 상한·속도 판정은 fn-eclipse-defend
|
||||
}
|
||||
|
||||
// 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다.
|
||||
// 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다.
|
||||
// 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」
|
||||
match /fandoms/{fandomId}/eclipses/{eclipseId}/shards/{shardId} {
|
||||
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/shards/{shardId} {
|
||||
allow read: if signedIn(); // 합산은 클라이언트가 해도 무방
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 방어 로그(라이브 소셜 프루프 원천).
|
||||
// 방어 로그(이클립스 단위 원장). 원천은 여기고, 화면이 구독하는 것은 아래
|
||||
// 팬덤 단위 liveEvents 다 — 티커는 활성 이클립스 ID 를 모른 채 떠야 한다.
|
||||
// 화면: S-G-ECLIPSE(실시간 방어 로그)
|
||||
match /fandoms/{fandomId}/eclipses/{eclipseId}/defenseLogs/{logId} {
|
||||
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/defenseLogs/{logId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 라이브 소셜 프루프(방어 참여 · 이후 아이템 사용). 홈 라이브 띠와 갤럭시
|
||||
// 섬광 배너가 이 컬렉션을 구독한다. 닉네임·POP·시각만 담는다.
|
||||
// 클라이언트가 쓰면 「누가 방어했다」를 위조할 수 있어 Functions 전용이다.
|
||||
// 화면: S-H-HOME(라이브 띠) · S-G-GALAXY(섬광 배너)
|
||||
// 근거: 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프 실시간 노출」
|
||||
match /fandoms/{fandomId}/liveEvents/{eventId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
@@ -277,6 +453,14 @@ service cloud.firestore {
|
||||
allow write: if false; // 수령은 fn-herodrop-claim 경유
|
||||
}
|
||||
|
||||
// Hero Gift 상품 Catalog — 원장 B-85. 문서 ID = 스토어 상품 ID.
|
||||
// 화면: 홈 「아이템 구입」 · Admin `관리 마스터_아이템 관리`(fnAdminWrite 경유)
|
||||
// 지급량(buyerPop · dropPop)은 `fn-iap-verify` 가 여기서 읽으므로 클라이언트 쓰기를 막는다.
|
||||
match /heroGiftProducts/{productId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 단결형 아이템 발동 상태.
|
||||
// 화면: S-M-SHOP · 근거: 별첨 1 ⑥ 「지급 대상·배수·시간창 정확」
|
||||
match /fandoms/{fandomId}/solidarityBoosts/{boostId} {
|
||||
@@ -290,6 +474,22 @@ service cloud.firestore {
|
||||
match /quests/{questId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
|
||||
|
||||
// 공개 후 수정 이력(C-07). 쓰기는 fn-quest-edit 전용 — append-only.
|
||||
match /edits/{editId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 생성 Quest 제출 · 검토 · 지급(C-08 · C-17 · G-01 · G-03).
|
||||
// 제출자 · 그 Quest 의 생성자 · 운영자만 읽는다. 상태 전이와 예약은 fn-quest-submit /
|
||||
// fn-quest-review 가 원장과 한 트랜잭션에서 하므로 클라이언트 쓰기는 없다.
|
||||
match /questSubmissions/{submissionId} {
|
||||
allow read: if signedIn() && (resource.data.uid == request.auth.uid
|
||||
|| resource.data.questAuthorUid == request.auth.uid
|
||||
|| isOps());
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL
|
||||
@@ -298,10 +498,23 @@ service cloud.firestore {
|
||||
allow write: if isOps();
|
||||
}
|
||||
|
||||
// AI Daily Briefing 카드(Functions 생성 → 캐시). 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
|
||||
// AI Daily Briefing 카드. 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
|
||||
//
|
||||
// **게시분만 공개한다.** 예전 규칙은 `if true` 였는데, 그러면 승인 전 Draft 가
|
||||
// 누구에게나 읽혔다. 별첨 B B-47 의 판정 기준이 「미승인 Draft 노출 0건」이므로
|
||||
// 규칙 층에서 막는다 — 앱이 걸러 주는 것과 읽을 수 없는 것은 다르다.
|
||||
// 로그인 없는 웹 브릿지 열람(D-16)은 게시분만 필요하므로 그대로 열려 있다.
|
||||
//
|
||||
// 앱 질의가 `status == 'published'` 를 포함하므로 목록 조회도 통과한다.
|
||||
// 운영자는 검수를 위해 Draft 를 봐야 해서 `isViewer()` 를 함께 둔다.
|
||||
//
|
||||
// **쓰기는 함수만 한다.** 예전 규칙의 `isOps()` 는 `fn-briefing-publish` 를
|
||||
// 우회해 상태를 바꿀 수 있는 길이었고, 그 경로로 바뀐 게시는 `adminActionLogs`
|
||||
// 에 남지 않는다. 누가 무엇을 승인했는지가 사라지면 B-47 의 승인 게이트가
|
||||
// 사실상 없는 것과 같다.
|
||||
match /briefings/{briefingId} {
|
||||
allow read: if true; // 웹 브릿지 열람 — D-16
|
||||
allow write: if isOps(); // 생성은 Functions, 수동 편집은 운영
|
||||
allow read: if resource.data.status == 'published' || isViewer();
|
||||
allow write: if false; // 수신·승인은 Functions 전용
|
||||
}
|
||||
|
||||
// 배지 도감 마스터. 화면: S-M-BADGE
|
||||
@@ -326,24 +539,18 @@ service cloud.firestore {
|
||||
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
|
||||
allow read: if campaignIsPublic()
|
||||
|| isOps()
|
||||
|| (signedIn() && resource.data.creatorUid == request.auth.uid);
|
||||
// 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로
|
||||
// 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다.
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'fandomId','creatorUid','creatorName','title','summary','body',
|
||||
'coverImageUrl','campaignType','targetPop','endsAt','status',
|
||||
'raisedPop','achievedRate','donorCount',
|
||||
'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.refundState == 'none';
|
||||
|| (signedIn() && resource.data.creatorUid == request.auth.uid)
|
||||
// 회사 귀책 취소(B-58)는 후원자에게 환불 알림이 가고 그 링크가 이 문서를 연다.
|
||||
// 후원 문서 ID 가 거래 ID 라 규칙이 찾을 수 없어, fn-campaign-fund 가 같은
|
||||
// 트랜잭션에 쓰는 `backers/{uid}` 표식으로 후원 여부를 본다. 철회(withdrawn)는
|
||||
// 공개 전·첫 후원 전에만 가능해 후원자가 없으므로 계속 비공개다.
|
||||
|| (signedIn() && resource.data.status == 'cancelled'
|
||||
&& exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid)));
|
||||
// 개설은 `fn-campaign-create` 전용이다. 등록비 차감(B-57)과 자격 판정(B-56),
|
||||
// 심사 대기(review) 진입이 한 트랜잭션이어야 하고, 개설자는 status 를 쓸 수 없어 직접
|
||||
// 만든 draft 는 심사로 넘어갈 길이 없었다. 화이트리스트 · 집계 초기값
|
||||
// 판정은 이제 함수가 필드를 직접 정해 쓰는 것으로 대신한다.
|
||||
allow create: if false;
|
||||
// 개설자는 심사 전 초안만 고칠 수 있다.
|
||||
allow update: if signedIn()
|
||||
&& resource.data.creatorUid == request.auth.uid
|
||||
@@ -358,23 +565,81 @@ service cloud.firestore {
|
||||
|
||||
// 후원 내역. POP 차감과 1:1이라 전량 서버 경유.
|
||||
// 화면: S-C-FUND · S-C-MINE · S-C-REFUND
|
||||
// 캠페인 좋아요. 문서 ID = uid 로 중복을 구조적으로 막고, 개수는 앱이
|
||||
// 집계 쿼리로 센다 — 캠페인 문서의 화이트리스트를 넓히지 않는다. 공개 캠페인에만 누른다.
|
||||
match /campaigns/{campaignId}/likes/{uid} {
|
||||
allow read: if signedIn();
|
||||
allow create: if isSelf(uid)
|
||||
&& request.resource.data.keys().hasOnly(['createdAt'])
|
||||
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
|
||||
.data.status in ['funding','success','fail','report'];
|
||||
allow delete: if isSelf(uid);
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
// 응원 게시판 댓글. 화면: S-C-DETAIL 응원 게시판 탭 · 시안 `527:21190`.
|
||||
// 라운지 댓글과 같은 틀 — 표시명은 공개 닉네임만(M-7), 서버 시각, 1,000자.
|
||||
// 누가 쓰는가: 운영값 `config/policy.campaignCommentScope`(all · members · backers).
|
||||
// 명세에 제한이 없어 잠정 'all' = 로그인 회원 누구나.
|
||||
// 공개 캠페인에만 단다. `commentCount` 는 fnCampaignCommentCount 가 다시 센다.
|
||||
match /campaigns/{campaignId}/comments/{commentId} {
|
||||
// 숨김 댓글은 운영만 본다. 목록 질의는 `visibility == 'visible'` 로 좁혀야 통과한다.
|
||||
allow read: if isOps() || (signedIn() && resource.data.visibility == 'visible');
|
||||
allow create: if authorIsSelf()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
||||
])
|
||||
// 응원 게시판은 「팬」 대체 이름을 받지 않는다 — 공개 닉네임이 있어야 쓴다.
|
||||
&& hasPublicNickname()
|
||||
&& authorNameIsPublic()
|
||||
&& request.resource.data.visibility == 'visible'
|
||||
&& bodyOk(1000)
|
||||
&& createdNow() && updatedNow()
|
||||
&& campaignOpenFor(campaignId)
|
||||
&& campaignCommenter(campaignId);
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
// 운영이 숨긴 댓글은 작성자가 지워 증거를 없애지 못한다.
|
||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& resource.data.visibility == 'visible';
|
||||
}
|
||||
|
||||
// 응원 게시판 댓글 좋아요(시안 `527:21192`). 문서 ID = uid 로 1인 1회.
|
||||
// 댓글의 `likeCount` 는 fnCampaignCommentLikeCount 가 다시 센다 — 댓글 문서는
|
||||
// 운영의 숨김 외에 수정 경로가 없어 위조할 수 없다.
|
||||
match /campaigns/{campaignId}/comments/{commentId}/likes/{uid} {
|
||||
allow read: if signedIn();
|
||||
allow create: if isSelf(uid)
|
||||
&& request.resource.data.keys().hasOnly(['createdAt'])
|
||||
&& createdNow()
|
||||
&& get(/databases/$(database)/documents/campaigns/$(campaignId)/comments/$(commentId))
|
||||
.data.visibility == 'visible'
|
||||
&& campaignOpenFor(campaignId)
|
||||
// 댓글을 쓸 수 없는 사람은 좋아요도 누를 수 없다(같은 작성 자격).
|
||||
&& campaignCommenter(campaignId);
|
||||
allow delete: if isSelf(uid);
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
// 후원자 표식. 문서 ID = uid. `fn-campaign-fund` 가 후원 문서와 같은 트랜잭션에 쓴다.
|
||||
match /campaigns/{campaignId}/backers/{uid} {
|
||||
allow read: if isSelf(uid) || isOps();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
match /campaigns/{campaignId}/donations/{donationId} {
|
||||
allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL)
|
||||
allow write: if false; // 별첨1 ⑤ 환불 무결성
|
||||
}
|
||||
|
||||
// 결과 보고. 성공 캠페인 개설자만 1회 제출하고 이후 운영이 관리한다.
|
||||
// 결과 보고. 성공 캠페인 개설자(또는 운영)가 1회 제출하고 이후 운영이 관리한다.
|
||||
// 화면: S-C-RESULT
|
||||
match /campaigns/{campaignId}/reports/{reportId} {
|
||||
allow read: if true;
|
||||
// §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';
|
||||
// 대상은 성공한 캠페인이다(별첨1 ⑤ 「결과보고 게시 확인」).
|
||||
// 제출은 `fn-campaign-report-submit` 전용이다. 보고 문서와 캠페인 상태 `report`
|
||||
// 전이·후원자 알림이 한 번에 서야 하는데, 개설자는 캠페인 status 를 쓸 수 없어
|
||||
// 클라이언트 직접 생성으로는 「보고는 있는데 상태는 success」가 남는다.
|
||||
allow create: if false;
|
||||
allow update: if isOps();
|
||||
allow delete: if false;
|
||||
}
|
||||
@@ -393,23 +658,36 @@ service cloud.firestore {
|
||||
'fandomId','authorUid','authorName','body','imageUrls',
|
||||
'likeCount','commentCount','visibility','createdAt','updatedAt'
|
||||
])
|
||||
&& authorNameMatchesProfile()
|
||||
&& authorNameIsPublic()
|
||||
&& fandomIsMine()
|
||||
&& request.resource.data.likeCount == 0
|
||||
&& request.resource.data.commentCount == 0
|
||||
&& request.resource.data.visibility == 'visible';
|
||||
&& request.resource.data.visibility == 'visible'
|
||||
&& bodyOk(5000)
|
||||
&& createdNow() && updatedNow()
|
||||
// 1 장 용량 · 형식 · 파일 이름은 storage.rules 가 본다.
|
||||
&& loungeImagesOk(postId);
|
||||
// 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
|
||||
// 사진은 작성 후 바꾸지 않는다 — 본문만 고친다.
|
||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& touchesOnly(['body','imageUrls','updatedAt']);
|
||||
&& touchesOnly(['body','updatedAt'])
|
||||
&& bodyOk(5000)
|
||||
&& updatedNow();
|
||||
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
||||
}
|
||||
|
||||
// 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다.
|
||||
// likeCount 는 fnPostLikeCount 트리거가 하위 문서 수를 다시 세어 적는다 —
|
||||
// 클라이언트는 글 문서의 수를 못 건드리므로(위 touchesOnly) 위조 경로가 없다.
|
||||
match /posts/{postId}/likes/{uid} {
|
||||
allow read: if signedIn();
|
||||
allow create, delete: if isSelf(uid); // 집계 반영은 Functions 트리거
|
||||
allow create: if isSelf(uid)
|
||||
&& request.resource.data.keys().hasOnly(['createdAt'])
|
||||
&& createdNow()
|
||||
&& canReactTo(postId);
|
||||
allow delete: if isSelf(uid); // 취소는 회원 자격과 무관하게 연다
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
@@ -421,10 +699,15 @@ service cloud.firestore {
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
||||
])
|
||||
&& authorNameMatchesProfile()
|
||||
&& request.resource.data.visibility == 'visible';
|
||||
&& authorNameIsPublic()
|
||||
&& request.resource.data.visibility == 'visible'
|
||||
&& bodyOk(1000)
|
||||
&& createdNow() && updatedNow()
|
||||
&& canReactTo(postId); // 팬덤 회원만 · 보이는 글에만
|
||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& touchesOnly(['body','updatedAt']);
|
||||
&& touchesOnly(['body','updatedAt'])
|
||||
&& bodyOk(1000)
|
||||
&& updatedNow();
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
||||
}
|
||||
@@ -446,9 +729,15 @@ service cloud.firestore {
|
||||
'authorUid','authorName','body','sourceUrls','accepted',
|
||||
'visibility','createdAt','updatedAt'
|
||||
])
|
||||
&& authorNameMatchesProfile()
|
||||
&& request.resource.data.accepted == false;
|
||||
&& authorNameIsPublic()
|
||||
&& request.resource.data.accepted == false
|
||||
// 서버 시각만 받는다 — 과거 시각으로 최신순 정렬을 흔들지 못하게.
|
||||
&& request.resource.data.createdAt == request.time
|
||||
// 채택·만료된 질문에는 답변을 달지 않는다(현상금이 이미 정산됨).
|
||||
&& get(/databases/$(database)/documents/questions/$(questionId)).data.status == 'open';
|
||||
// 채택된 답변은 본문을 고칠 수 없다 — 채택 후 내용 바꿔치기 차단.
|
||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& resource.data.accepted == false
|
||||
&& touchesOnly(['body','sourceUrls','updatedAt']);
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
allow delete: if false; // 채택 대상이라 삭제 대신 숨김
|
||||
@@ -459,9 +748,37 @@ service cloud.firestore {
|
||||
match /reports/{reportId} {
|
||||
allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid);
|
||||
allow list: if isOps();
|
||||
// 설계서 §2.6 reports 스키마로만 받는다. 문서 ID 를 「post-{글}-{신고자}」로
|
||||
// 규칙이 묶어 두므로 같은 사람의 재신고는 update 가 되어 아래에서 거부되고,
|
||||
// 임의 ID 로 같은 글을 여러 번 신고할 수도 없다(B-45 「신고자 수」).
|
||||
// 지금 앱이 접수하는 대상은 라운지 글 · 캠페인(`campaign-{캠페인}-{신고자}`) ·
|
||||
// 캠페인 응원 게시판 댓글(`campaignComment-{캠페인}-{댓글}-{신고자}`)이다. 라운지 댓글 · 답변 · 회원
|
||||
// 신고는 앱 경로가 생길 때 같은 방식으로 ID 를 묶어 연다.
|
||||
// 사유 코드는 Figma 사본 신고 사유 4항목(`moderation.dart`)의 코드다.
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'reporterUid','targetType','targetPath','reasonCode',
|
||||
'status','createdAt'
|
||||
])
|
||||
&& request.resource.data.reporterUid == request.auth.uid
|
||||
&& request.resource.data.status == 'pending';
|
||||
&& request.resource.data.targetPath is string
|
||||
&& ((request.resource.data.targetType == 'post'
|
||||
&& request.resource.data.targetPath.matches('posts/[^/]+')
|
||||
&& reportId == 'post-' + request.resource.data.targetPath.split('/')[1]
|
||||
+ '-' + request.auth.uid)
|
||||
|| (request.resource.data.targetType == 'campaign'
|
||||
&& request.resource.data.targetPath.matches('campaigns/[^/]+')
|
||||
&& reportId == 'campaign-' + request.resource.data.targetPath.split('/')[1]
|
||||
+ '-' + request.auth.uid)
|
||||
|| (request.resource.data.targetType == 'campaignComment'
|
||||
&& request.resource.data.targetPath.matches('campaigns/[^/]+/comments/[^/]+')
|
||||
&& reportId == 'campaignComment-' + request.resource.data.targetPath.split('/')[1]
|
||||
+ '-' + request.resource.data.targetPath.split('/')[3]
|
||||
+ '-' + request.auth.uid))
|
||||
&& request.resource.data.reasonCode in
|
||||
['defamation','inappropriate','spam','privacy']
|
||||
&& request.resource.data.status == 'pending'
|
||||
&& createdNow();
|
||||
allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']);
|
||||
allow delete: if false;
|
||||
}
|
||||
@@ -469,8 +786,19 @@ service cloud.firestore {
|
||||
// 문의. 화면: S-M-INQUIRY · S-AD-REPORT
|
||||
match /inquiries/{inquiryId} {
|
||||
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
||||
allow list: if isOps();
|
||||
// 내 문의 목록(S-M-INQUIRY · Figma `373:634916`)이 자기 문의를 열거한다.
|
||||
// 규칙이 문서마다 `authorUid` 를 보므로, 질의가 `authorUid == uid` 로
|
||||
// 좁혀지지 않으면 **질의 전체가 거부**된다 — 남의 문의는 한 건도 새지 않는다.
|
||||
allow list: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
||||
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
|
||||
// 작성자 수정 — Figma 문의 수정 `554:24330` · 상세의 「수정」 `373:635123`.
|
||||
// 접수(`pending`) 상태에서만, 제목과 본문만 고칠 수 있다. `status` 를 함께
|
||||
// 건드리면 거부되므로 스스로 「답변 완료」로 올릴 수 없고, 운영이 상태를
|
||||
// 올린 뒤에는 이 갈래가 닫힌다.
|
||||
allow update: if signedIn()
|
||||
&& resource.data.authorUid == request.auth.uid
|
||||
&& resource.data.status == 'pending'
|
||||
&& touchesOnly(['title','body','updatedAt']);
|
||||
allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']);
|
||||
allow delete: if false;
|
||||
}
|
||||
@@ -522,7 +850,8 @@ service cloud.firestore {
|
||||
}
|
||||
|
||||
// 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다.
|
||||
// 남용 방지를 위해 문서에는 사용 횟수 등 최소 필드만 두고 개인정보를 담지 않는다.
|
||||
// 문서에는 `{active}` 만 둔다 — 코드 주인(UID)은 서버 전용 `inviteCodeOwners/{code}`
|
||||
// (기본 거부 구역)에 있어 코드로 사용자를 알아낼 수 없다.
|
||||
// 화면: S-W-PREREG(코드 입력) · E-PRE-01
|
||||
match /inviteCodes/{code} {
|
||||
allow get: if true; // 코드 직접 조회만 허용
|
||||
@@ -545,12 +874,19 @@ service cloud.firestore {
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 마킹. 문서 ID = uid 로 중복 마킹을 차단하고, 본인 번역 자기 마킹은 서버가 막는다(E-TRN-01).
|
||||
// 번역 일일 카운터(`{uid}_{dayKey}`). 도배 판정 입력이라 서버 전용.
|
||||
match /translationDaily/{docId} {
|
||||
allow read, write: if false;
|
||||
}
|
||||
|
||||
// 마킹. 문서 ID = uid 로 중복 마킹을 차단한다. 쓰기는 fn-translation-mark 만 —
|
||||
// 본인 번역(E-TRN-01) · 팬덤 멤버 · 가입 24시간(E-TRN-04) 판정이 규칙으로는 안 된다.
|
||||
// 읽기는 번역 작성자 · 마킹한 본인 · 운영자만 — 수는 번역 문서(validCount · invalidCount)에 있다.
|
||||
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 read: if signedIn()
|
||||
&& (request.auth.uid == uid || isOps()
|
||||
|| get(/databases/$(database)/documents/translationEntries/$(entryId)).data.authorUid == request.auth.uid);
|
||||
allow create: if false;
|
||||
allow update, delete: if false; // 번복 불가 — 어뷰징 가드
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user