An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.
- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
POST /complete. 404 while discord_oauth_redirect_uri is empty or no
usable bot has a secret. Bot order: explicit token_id, the node system's
preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
state.uid must be the caller; any presentation burns it), code exchange,
revoke, client-credentials secret check. Through discord_rest's shared
client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
settle step that stops a server's streams when the org it acted for
changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7.3 KiB
Plaintext
195 lines
7.3 KiB
Plaintext
// Firestore security rules — the actual tenant boundary for DRB.
|
|
//
|
|
// WHY THIS FILE EXISTS: drb-frontend reads Firestore directly from the
|
|
// browser (see lib/use*.ts — onSnapshot(collection(db, ...))), so c2-core's
|
|
// app/internal/auth.py is NOT in that read path at all. These rules are the
|
|
// only thing standing between a signed-in stranger and every org's radio
|
|
// traffic. Before this file existed, whatever rules were live had been
|
|
// hand-set in the Firebase console: unversioned, unreviewed, unknown. See
|
|
// SAAS_PLAN.md B1.
|
|
//
|
|
// DEPLOYED BY CI on every push to main (.gitea/workflows/deploy.yml, job
|
|
// deploy-firestore-rules, service-account auth via the FIREBASE_SA_KEY
|
|
// secret — server-26#51). That job is separate from the app deploy, so a
|
|
// green app deploy does NOT mean these rules are live: check that job too.
|
|
// Editing rules in the Firebase console is overwritten by the next push.
|
|
//
|
|
// MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules
|
|
// entirely and is the sole writer for every collection below — that was
|
|
// already the architecture (see CLAUDE.md "Auth — three distinct
|
|
// mechanisms"). These rules therefore only need to gate READS for the
|
|
// browser client, and can safely deny ALL client writes.
|
|
//
|
|
// Deny-by-default: the catch-all match at the bottom denies anything not
|
|
// explicitly listed above it, including collections added later that
|
|
// someone forgets to add a rule for.
|
|
|
|
rules_version = '2';
|
|
|
|
service cloud.firestore {
|
|
match /databases/{database}/documents {
|
|
|
|
function signedIn() {
|
|
return request.auth != null;
|
|
}
|
|
|
|
// Platform-level role (admin/operator/viewer) — set by drb-c2-core
|
|
// routers/users.py custom claims. Distinct from org_role (owner/member),
|
|
// which is per-organization. A platform admin can read across every org
|
|
// (support/debugging), mirroring internal/auth.py's require_org()
|
|
// ?org_id= override for the same role.
|
|
function isPlatformAdmin() {
|
|
return signedIn() &&
|
|
(request.auth.token.role == 'admin' || request.auth.token.admin == true);
|
|
}
|
|
|
|
// The org_id claim is set by POST /auth/signup (or /admin/users) at
|
|
// account-provisioning time. No claim => no access, by construction —
|
|
// this is what backs AuthProvider's no-claim guard (SAAS_PLAN.md B3):
|
|
// a user with no org_id claim can hold a valid Firebase session and
|
|
// still read nothing here.
|
|
function myOrgId() {
|
|
return request.auth.token.org_id;
|
|
}
|
|
|
|
function inOrg(orgId) {
|
|
return signedIn() && (myOrgId() == orgId || isPlatformAdmin());
|
|
}
|
|
|
|
function docInMyOrg() {
|
|
return inOrg(resource.data.org_id);
|
|
}
|
|
|
|
// ── Org identity ──────────────────────────────────────────────────────
|
|
match /organizations/{orgId} {
|
|
allow read: if inOrg(orgId);
|
|
allow write: if false; // c2-core only (POST /auth/signup, routers/org.py)
|
|
}
|
|
|
|
match /org_members/{uid} {
|
|
allow read: if signedIn() && (request.auth.uid == uid || isPlatformAdmin() ||
|
|
inOrg(resource.data.org_id));
|
|
allow write: if false; // c2-core only
|
|
}
|
|
|
|
// ── Tenant-scoped radio data — the whole point of this file ───────────
|
|
match /nodes/{nodeId} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
match /systems/{systemId} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
match /calls/{callId} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
match /incidents/{incidentId} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
// Live map overlays fed by a node's second SDR (node-26#9). Snapshot
|
|
// docs, one per icao/mmsi, last-seen-wins — not a history collection.
|
|
match /aircraft/{icao} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
|
|
// Flight trail points. Org is checked against the PARENT aircraft doc
|
|
// (one get() per query) so the map can query a trail by time alone,
|
|
// without an org_id filter and the composite index that would need.
|
|
match /positions/{pointId} {
|
|
allow read: if inOrg(get(/databases/$(database)/documents/aircraft/$(icao)).data.org_id);
|
|
allow write: if false;
|
|
}
|
|
}
|
|
|
|
match /vessels/{mmsi} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
match /alert_events/{alertId} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
match /alert_rules/{ruleId} {
|
|
allow read: if docInMyOrg();
|
|
allow write: if false;
|
|
}
|
|
|
|
// ── Never client-readable, org-scoped or not ───────────────────────────
|
|
// Secrets / credential material. Reads for these go through c2-core
|
|
// REST routes (which apply their own auth), never straight to Firestore.
|
|
match /node_keys/{nodeId} {
|
|
allow read, write: if false;
|
|
}
|
|
|
|
match /enrollment_tokens/{tokenHash} {
|
|
allow read, write: if false; // routers/org.py mints/lists/revokes server-side
|
|
}
|
|
|
|
match /org_api_keys/{keyId} {
|
|
allow read, write: if false; // not implemented server-side yet (DEFERRED.md) — deny regardless
|
|
}
|
|
|
|
// ── Platform-admin-only collections ────────────────────────────────────
|
|
// Listed here mainly so the deny-default catch-all's intent is explicit;
|
|
// these are already read/written exclusively through c2-core admin
|
|
// routes (require_admin_token), never straight from the browser.
|
|
match /audit_log/{entryId} {
|
|
allow read, write: if false;
|
|
}
|
|
|
|
match /config/{docId} {
|
|
allow read, write: if false;
|
|
}
|
|
|
|
match /bot_tokens/{tokenId} {
|
|
allow read, write: if false;
|
|
}
|
|
|
|
// Radio-bot OAuth2 client secrets and in-flight "Connect a Discord
|
|
// server" states (server-26#225 S6). Secrets: c2-core only, never any client.
|
|
match /bot_oauth/{tokenId} {
|
|
allow read, write: if false;
|
|
}
|
|
|
|
match /discord_oauth_states/{stateHash} {
|
|
allow read, write: if false;
|
|
}
|
|
|
|
match /waitlist/{entryId} {
|
|
allow read, write: if false; // POST /waitlist writes via the admin SDK
|
|
}
|
|
|
|
// ── Trips (internal utility feature, not org-scoped — see
|
|
// [[trips-feature-intentional]] and SAAS_PLAN.md B7. Frontend hides
|
|
// /trips outside the founding org; these rules keep the existing
|
|
// "public unless flagged private" trip model working for whichever
|
|
// users the UI still exposes it to) ────────────────────────────────────
|
|
match /trips/{tripId} {
|
|
allow read: if signedIn();
|
|
allow write: if false;
|
|
}
|
|
|
|
match /trip_events/{eventId} {
|
|
allow read: if signedIn();
|
|
allow write: if false;
|
|
}
|
|
|
|
// ── Deny-by-default catch-all ──────────────────────────────────────────
|
|
// Anything not explicitly matched above — including collections added
|
|
// later without a corresponding rule — is denied. This is the guard
|
|
// rail: a missing rule fails closed, not open.
|
|
match /{document=**} {
|
|
allow read, write: if false;
|
|
}
|
|
}
|
|
}
|