공식 로고 앱 아이콘 적용 · 확정 정책에 맞춰 등급·POP 소멸 필드를 규칙·인덱스에서 제외

This commit is contained in:
Sn-Kinos
2026-08-14 00:46:56 +09:00
parent 832cb88c30
commit 97ea5fad35
41 changed files with 51 additions and 77 deletions
-17
View File
@@ -74,23 +74,6 @@
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "popLots",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "popType", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "grantedAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "popLots",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "expiresAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "activityLogs",
"queryScope": "COLLECTION",
+12 -14
View File
@@ -8,7 +8,7 @@ rules_version = '2';
// - 주간보고/1주차/src/1주차_정책설계서_초안.md P-02 원장 무결성 · P-06 어뷰징 방어
//
// 3대 원칙
// 1) POP · FPI · 등급 · 원장 필드는 클라이언트가 쓸 수 없다.
// 1) POP · FPI 집계 · 원장 필드는 클라이언트가 쓸 수 없다.
// 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다.
// → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장.
// 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다.
@@ -58,13 +58,13 @@ service cloud.firestore {
}
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08)
function campaignIsPublic() {
return resource.data.status in ['voting', 'funding', 'success', 'fail', 'report'];
return resource.data.status in ['funding', 'success', 'fail', 'report'];
}
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
// 근거: 결정 D-07·D-13·D-14·D-21·D-22·D-23 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
// 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
match /config/{configId} {
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
@@ -92,14 +92,14 @@ service cloud.firestore {
// ───────────────────────── 2. 사용자 ─────────────────────────
// 계정·프로필. 등급(grade)·누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다.
// 계정·프로필. 누적 기여(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 이 허용 목록에 없어 필드가 빈
// ② 클라이언트가 먼저 만들면 totalContributedPop 등 서버 산출 필드가 빈
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
// 생성 주체를 Auth 트리거 하나로 고정한다.
allow create: if false;
@@ -119,14 +119,14 @@ service cloud.firestore {
allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」
}
// POP Lot — 획득 단위별 잔량·만료일. FIFO 차감의 계산 단위.
// 화면: S-M-WALLET(소멸 예정 POP)
// POP Lot — 획득 단위별 잔량. 원장 대사와 차감의 계산 단위.
// 화면: S-M-WALLET
match /users/{uid}/popLots/{lotId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 잔량·만료 조작 차단
allow write: if false; // 잔량 조작 차단
}
// POP 원장. 지급·차감·환불·소멸 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다.
// POP 원장. 지급·차감·환불·보류 무효 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다.
// 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER
match /users/{uid}/popTransactions/{txId} {
allow read: if isSelf(uid) || isViewer();
@@ -147,7 +147,7 @@ service cloud.firestore {
allow write: if false;
}
// 보유 배지·등급 이력. 부여는 서버 판정.
// 보유 배지 이력. 부여는 서버 판정.
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
//
// 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
@@ -326,7 +326,7 @@ service cloud.firestore {
&& request.resource.data.keys().hasOnly([
'fandomId','creatorUid','creatorName','title','summary','body',
'coverImageUrl','campaignType','targetPop','endsAt','status',
'raisedPop','achievedRate','donorCount','votedPop','isAiDraft',
'raisedPop','achievedRate','donorCount',
'refundState','createdAt','updatedAt'
])
&& request.resource.data.creatorUid == request.auth.uid
@@ -336,8 +336,6 @@ service cloud.firestore {
&& 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()
@@ -618,7 +616,7 @@ service cloud.firestore {
allow read: if signedIn();
}
// popLots(IDX-11)·환불 배치 donations(IDX-21)는 스케줄 Function 전용이다.
// 환불 배치 donations(IDX-21)는 스케줄 Function 전용이다.
// Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다.
// ───────────────────────── 13. 기본 거부 ─────────────────────────
@@ -49,11 +49,9 @@ export function userDocument(uid, overrides = {}) {
uid,
displayName: uid,
fandomId: 'fandom-a',
grade: 'SILVER',
totalContributedPop: 0,
status: 'active',
updatedAt: '2026-08-08T00:00:00Z',
...overrides,
};
}
@@ -43,7 +43,7 @@ afterAll(async () => {
await testEnvironment.cleanup();
});
describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가', () => {
describe('원칙 1 — POP·FPI·원장은 클라이언트 쓰기 불가', () => {
test('본인 지갑 잔액 직접 증액을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/wallet/main': { balance: 100 },
@@ -61,14 +61,14 @@ describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가
);
});
test('POP Lot 만료일 연장을 거부한다', async () => {
test('POP Lot 잔량 조작을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/popLots/lot-1': { remaining: 100, expiresAt: '2026-08-09T00:00:00Z' },
'users/alice/popLots/lot-1': { remaining: 100 },
});
await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice/popLots/lot-1'), {
expiresAt: '2099-12-31T00:00:00Z',
remaining: 999_999,
}),
);
});
@@ -82,12 +82,14 @@ describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가
);
});
test('사용자 등급 직접 승격을 거부한다', async () => {
test('누적 기여 통계 직접 조작을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice'),
});
await assertFails(updateDoc(doc(dbFor('alice'), 'users/alice'), { grade: 'DIAMOND' }));
await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice'), { totalContributedPop: 999_999 }),
);
});
test('다른 사용자의 지갑 쓰기를 거부한다', async () => {
@@ -125,11 +127,11 @@ describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => {
await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob'), { displayName: 'Forged' }));
});
test('허용 필드와 등급을 섞은 본인 업데이트를 전부 거부한다', async () => {
test('허용 필드와 서버 통계를 섞은 본인 업데이트를 전부 거부한다', async () => {
await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice'), {
displayName: 'New Alice',
grade: 'DIAMOND',
totalContributedPop: 999_999,
}),
);
});
@@ -251,15 +253,13 @@ describe('F-02 — 캠페인 집계 필드 위조 회귀', () => {
summary: 'Summary',
body: 'Body',
coverImageUrl: 'https://example.test/cover.png',
campaignType: 'support',
campaignType: 'other',
targetPop: 1000,
endsAt: '2026-09-01T00:00:00Z',
status: 'draft',
raisedPop: 0,
achievedRate: 0,
donorCount: 0,
votedPop: 0,
isAiDraft: false,
refundState: 'none',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
@@ -275,14 +275,12 @@ describe('F-02 — 캠페인 집계 필드 위조 회귀', () => {
await assertSucceeds(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign));
});
test('집계·투표·AI 초안 필드 위조를 거부한다', async () => {
test('집계 필드 위조를 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'campaigns/forged'), {
...validCampaign,
achievedRate: 1,
donorCount: 99_999,
votedPop: 999_999,
isAiDraft: true,
}),
);
});
@@ -389,7 +387,10 @@ describe('F-14 — posts 허용 필드 회귀', () => {
describe('F-06 — users 클라이언트 생성 경로 회귀', () => {
test('일반 사용자의 users 문서 생성을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'users/alice'), userDocument('alice', { grade: 'DIAMOND' })),
setDoc(
doc(dbFor('alice'), 'users/alice'),
userDocument('alice', { totalContributedPop: 999_999 }),
),
);
});