All files / src/utils segregation-audit.js

93.33% Statements 28/30
95.83% Branches 23/24
88.88% Functions 8/9
93.33% Lines 28/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129                                                  68x     12x 10x   2x       12x 12x                                                           68x 68x 68x     17x 17x 17x 17x 5x 5x 5x   12x 12x       12x     28x         17x 17x 17x 5x   12x                                               68x   28x       68x  
/**
 * UK OSA #17 PR 8 + PR 11 — segregation audit helpers.
 *
 * Centralised here (not in `src/middleware/sameCohort.js`) so the
 * helpers are callable from any route file or background utility
 * without dragging in the `requireSameCohort` middleware machinery.
 * The migration script (`scripts/migrate-segregation-relationships.js`)
 * writes the bulk audit rows; these helpers capture per-request and
 * per-dispatch events.
 *
 *   - `auditAdminFlagBypass` (PR 8): an admin moderator read a thread
 *     that was hidden by `crossCohortAtMigration: true`. Forensic
 *     record for UK OSA / GDPR Article 30 auditability.
 *   - `auditFcmCohortDrop` (PR 11): the FCM dispatcher silently
 *     dropped a push because sender and recipient resolved to
 *     different cohorts. Identities are passed directly because the
 *     dispatcher runs without a `req` context (cron, async fan-out,
 *     retry workers can all reach it).
 *
 * All audit writes are fire-and-forget by design. Failure must NEVER
 * block the calling code path (would itself be an existence side-
 * channel via timing or surfaced errors). Errors are swallowed —
 * supplemental telemetry, not the only signal.
 */
 
const { db } = require('./firebase');
 
function surfaceOf(req) {
  if (req?.route?.path) {
    return `${req.baseUrl || ''}${req.route.path}`;
  }
  return `${req.baseUrl || ''}${req?.path || ''}`;
}
 
function auditAdminFlagBypass(req, conversationId) {
  const callerId = String(req?.auth?.uniqueId ?? '');
  db.collection('segregationEvents')
    .add({
      sourceUniqueId: callerId,
      sourceCohort: req?.auth?.token?.cohort || 'unknown',
      targetUniqueId: conversationId,
      targetConversationId: conversationId,
      targetCohort: 'mixed',
      surface: surfaceOf(req),
      action: 'admin_flag_bypass',
      timestamp: Date.now(),
      requestId: req?.id ?? null,
    })
    .catch(() => {
      // Audit-write failure does not surface to the response: a
      // failed audit is a known-acceptable trade-off here since the
      // migration script already wrote a row per migrated thread.
      // Logging a failure here would also leak "audit attempted"
      // signals across requests.
    });
}
 
// PR 11 audit-write dedup. Mirrors the rationale in
// `middleware/sameCohort.js`: a determined attacker could spam cross-
// cohort pushes (DMs, room invites, seat requests) to force a write
// per call. Spark-tier daily-write budget (~20K) drains in minutes
// under attack, both DoS-ing the DEV project and corrupting the audit
// signal admins read. The DROP (load-bearing security action) stays
// unconditional in `fcm.js`; only the supplementary audit row is
// throttled. 1 write per (sender, recipient) pair per 5 minutes is the
// same window PR 4 uses for HTTP gates.
const FCM_AUDIT_DEDUP_WINDOW_MS = 5 * 60 * 1000;
const FCM_AUDIT_DEDUP_MAX_KEYS = 10_000;
const fcmAuditDedup = {
  hits: new Map(),
  shouldWrite(sourceId, targetId) {
    const key = `${sourceId}:${targetId}`;
    const now = Date.now();
    const entry = this.hits.get(key);
    if (entry && now < entry.expiresAt) {
      this.hits.delete(key);
      this.hits.set(key, entry);
      return false;
    }
    this.hits.set(key, { expiresAt: now + FCM_AUDIT_DEDUP_WINDOW_MS });
    while (this.hits.size > FCM_AUDIT_DEDUP_MAX_KEYS) {
      const oldestKey = this.hits.keys().next().value;
      this.hits.delete(oldestKey);
    }
    return true;
  },
  reset() {
    this.hits.clear();
  },
};
 
function auditFcmCohortDrop({ sourceUniqueId, sourceCohort, targetUniqueId, targetCohort }) {
  const sourceId = String(sourceUniqueId);
  const targetId = String(targetUniqueId);
  if (!fcmAuditDedup.shouldWrite(sourceId, targetId)) {
    return Promise.resolve();
  }
  return db
    .collection('segregationEvents')
    .add({
      sourceUniqueId: sourceId,
      sourceCohort,
      targetUniqueId: targetId,
      targetCohort,
      surface: 'fcm:dispatch',
      action: 'push_blocked',
      timestamp: Date.now(),
      requestId: null,
    })
    .catch(() => {
      // Swallowed — the drop is the load-bearing security action;
      // the audit row is supplemental. Logging here would itself be
      // an "audit failed" side-channel.
    });
}
 
// Test-only: reset the dedup store between tests. Gated behind a test-
// env check for the same reason as `sameCohort._resetAuditDedup` — a
// route inadvertently exporting this would let an attacker wipe the
// dedup window and DoS the Spark-tier write quota in production.
const _resetFcmAuditDedup =
  process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined
    ? function _resetFcmAuditDedupTestOnly() {
        fcmAuditDedup.reset();
      }
    : function _resetFcmAuditDedupNoop() {};
 
module.exports = { auditAdminFlagBypass, auditFcmCohortDrop, _resetFcmAuditDedup };