cozy-games/apps/mnswpr/firestore.rules
2026-07-03 23:04:52 +02:00

78 lines
3.4 KiB
Text

rules_version = '2'
// Security rules for the mnswpr leaderboard. Firestore is schemaless, so these
// rules are the enforced schema: public reads for the boards, validated
// create-only writes for scores, no client writes to server config.
//
// The Firebase web config that ships in the client is public by design (it is
// not a secret); these rules — not secrecy — are what govern access. No
// collection allows a client to update or delete existing docs. Keep any
// admin/service-account credentials out of the repo and locked down in GCP.
//
// Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the
// same set covers production (`mw-*`) and test (`mw-test-*`) collections in the
// one project — and any future namespace. Firestore ORs all matching rules, and
// `{ns}-scores`, `{ns}-all` and `{ns}-leaders` share a 4-segment shape, so each
// block is guarded by a regex on the captured collection name.
//
// Deploy with (see docs/firebase-leaderboards.md):
// pnpm -F mnswpr exec firebase deploy --only firestore:rules --project prod
service cloud.firestore {
match /databases/{database}/documents {
// Ranked, time-windowed scores — public read, validated create-only.
match /{scores}/{category}/games/{game} {
allow read: if scores.matches('mw(-[a-z]+)?-scores');
allow create: if scores.matches('mw(-[a-z]+)?-scores') && isValidScore(category);
allow update, delete: if false;
}
// Legacy all-time collection, frozen into the static Legends page — read-only.
match /{leaders}/{category}/games/{game} {
allow read: if leaders.matches('mw(-[a-z]+)?-leaders');
allow write: if false;
}
// Per-browser personal archive. Not readable by clients, append/merge only,
// and never deletable. The doc is a map of gameId -> game object (one doc
// per day, merged as games finish); the entry-count cap keeps a single doc
// bounded (Firestore already limits any doc to 1 MiB). 500 is far above any
// real day of play.
match /{all}/{browserId}/games/{session} {
allow read: if false;
allow create, update: if all.matches('mw(-[a-z]+)?-all')
&& request.resource.data.size() <= 500;
allow delete: if false;
}
// Server config (passingStatus, message) — public read, no client write.
match /{config}/{document} {
allow read: if config.matches('mw(-[a-z]+)?-config');
allow write: if false;
}
// Defense-in-depth: deny anything not matched above. Firestore already
// default-denies unmatched paths; this makes the intent explicit and is a
// backstop if a future rule is added carelessly. (Allows are OR-ed, so this
// never revokes the specific grants above.)
match /{document=**} {
allow read, write: if false;
}
// A well-formed score: numeric score, bounded name, string bucket keys, and
// a category matching the path. Rules have no clock, so they can't verify
// the buckets are correctly derived from time_stamp — that stays the client's
// job. Extra fields (playerId, time_stamp, meta) are allowed.
function isValidScore(category) {
let d = request.resource.data;
return d.score is number
&& d.score >= 0
&& d.category == category
&& d.name is string
&& d.name.size() <= 24
&& d.day is string
&& d.week is string
&& d.month is string;
}
}
}