BGBidGauge Documentation
Resolved from the previous pass Firestore and Storage rules are no longer open to any signed-in user. Both now require the caller's companyId custom claim to match the document/path being accessed, and user-creation/deletion/listing now goes exclusively through authorization-checked Cloud Functions rather than direct client writes.

Roles

Company Admin

Listed in companyPrefs.adminUsers / adminUids — explicit membership is now required (the old "empty list = everyone's admin" bootstrap fallback has been removed). Full access everywhere, plus the only role that can create/restore database backups.

Location Admin

Listed in companyPrefs.locationAdminUsers / locationAdminUids. Restricted to the Users tab, scoped to their assigned location(s) — enforced both client-side and inside the Cloud Functions that manage users.

Standard User

Capabilities entirely from userAccess[email] flags. New: canManageCompanyUsers can now be granted to a standard user directly, independent of location-admin status.

Service Manager

Unchanged — a Standard user with userAccess.canManageService === true.

Capability matrix

✓ = has access · flag = depends on a per-user userAccess flag · — = no access
CapabilityCompany AdminLocation AdminStandard User
Dashboard, Item Library, own User Preferences
Analytics pagecanViewAnalytics
Payment Applications pagecanViewPaymentApplications
Service Portal pagecanManageService
Approve proposals / change orderscanApproveProposals
Manage company users (invite/edit/delete)✓ (own location only)canManageCompanyUsers — new
Company Settings — full
Company Settings — Users tab only
Backup & Recovery — create / restore / list
Cross-location visibility✓ allown location(s)userAccess.locationIds

Multi-factor authentication

Two independent MFA mechanisms exist and can both be active for the same user:

Firebase-native TOTPCustom email-code MFA
How it's enrolledStandard Firebase Authenticator-app MFA enrollment (per-user)Company-wide toggle: companyPrefs.requireEmailLoginVerification
Challenge mechanismFirebase SDK's getMultiFactorResolver + TotpMultiFactorGenerator6-digit code emailed via Gmail API, verified against a hash stored server-side
Code storageManaged entirely by Firebase AuthSHA-256(random salt + code) in emailLoginVerificationCodes/{'{'}uid{'}'} — the raw code is never persisted, only the salted hash
Rate limitingFirebase-managed60s resend cooldown, 10-minute code expiry, 5 wrong-attempt lockout (doc deleted, caller must request a new code)
"Remembered device"Not applicableNot a cookie/localStorage flag — a per-company expiry timestamp written into the user's ID token custom claims (bidgaugeEmailMfaByCompany[companyId]) on successful verification, checked on each subsequent sign-in via a forced token refresh
Remember windowUser-chosen at verification time, clamped server-side to a maximum of 120 days; the company default offered in the sign-in checkbox is configurable (emailLoginVerificationRememberDays, falls back to 90) up to a 120-day ceiling
Note Because the remembered-device window lives in the ID token rather than browser storage, "remembering" a device is really "remembering this signed-in user for this company" — it follows the account across browsers/devices that share the same token cache behavior, and clears the moment the claim's timestamp is in the past regardless of where the sign-in happens.

Where enforcement happens now

LayerEnforcement
Sidebar navigationUnchanged — visiblePages filters nav links by the flags above.
User managementMoved entirely behind Cloud Functions. There is no direct client write path to create, invite, update, or delete a company user anymore — createCompanyUser, sendCompanyUserSetupEmail, sendCompanyUserPasswordResetEmail, updateCompanyUserProfile, deleteCompanyUser, and listCompanyUsers all run assertCompanyUserManager() (admin, location admin scoped to their location, or a user with canManageCompanyUsers) before doing anything.
BackupsassertCompanyAdmin() — stricter than the user-management check; only true Company Admins, no location-admin or delegated-flag exception.
Firestore data accessNow enforced by rules, not just UI hiding — see below.
Storage data accessNow enforced by rules, scoped per company — see below.

Firestore security rules

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() { return request.auth != null; }

    function authCompanyId() {
      return signedIn() && request.auth.token.companyId is string
        ? request.auth.token.companyId : "";
    }

    function hasCompanyClaim(companyId) {
      return signedIn() && authCompanyId() == companyId;
    }

    function isCompanyAdmin(data) {
      return signedIn() && (
        userEmail() in adminUsers(data) ||
        request.auth.uid in adminUids(data)
      );
    }

    match /companies/{companyId} {
      allow read: if hasCompanyClaim(companyId);
      allow create: if hasCompanyClaim(companyId) && createsCompanyAsAdmin(request.resource.data);
      allow update, delete: if hasCompanyClaim(companyId) && isCompanyAdmin(resource.data);
    }

    match /companies/{companyId}/projects/{projectId}/{document=**} {
      allow read, write: if hasCompanyClaim(companyId);
    }
    match /companies/{companyId}/projectSummaries/{projectId} {
      allow read, write: if hasCompanyClaim(companyId);
    }
    match /companies/{companyId}/itemLibrary/{itemId} {
      allow read, write: if hasCompanyClaim(companyId);
    }
    match /companies/{companyId}/editPresence/{presenceId} {
      allow read, write: if hasCompanyClaim(companyId);
    }

    match /companies/{companyId}/databaseBackups/{backupId} {
      allow read: if hasCompanyClaim(companyId) &&
        isCompanyAdmin(get(/databases/$(database)/documents/companies/$(companyId)).data);
      allow write: if false;
    }
    match /companies/{companyId}/passwordResetRequests/{requestId} {
      allow read, write: if false;
    }
    match /companies/{companyId}/emailLoginVerificationCodes/{codeId} {
      allow read, write: if false;
    }

    match /companies/{companyId}/{document=**} {
      allow read, write: if false;
    }

    match /users/{userId} {
      allow read, write: if signedIn() && request.auth.uid == userId;
    }
  }
}

(Helper functions adminUsers, adminUids, userEmail, createsCompanyAsAdmin etc. omitted above for length — same shape as isCompanyAdmin.)

Resolved The backup and MFA-code rule paths now correctly name the real collections (databaseBackups, emailLoginVerificationCodes), matching what the functions actually write to — the earlier name mismatch flagged in Technical Risk Notes has been fixed.

Storage security rules

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    function signedIn() { return request.auth != null; }

    function authCompanyId() {
      return signedIn() && request.auth.token.companyId is string
        ? request.auth.token.companyId : "";
    }

    function canAccessCompanyStorage(companyId) {
      return signedIn() && authCompanyId() == companyId;
    }

    function canAccessLegacyDefaultStorage() {
      return signedIn() && authCompanyId() == "02r9bp5nv0mdtxmndu4m";
    }

    match /companies/{companyId}/project-storage/{projectId}/{allPaths=**} {
      allow read, write: if canAccessCompanyStorage(companyId) ||
        (companyId == "default" && canAccessLegacyDefaultStorage());
    }

    match /project-storage/{projectId}/{allPaths=**} {
      allow read, write: if canAccessLegacyDefaultStorage();
    }
  }
}

Storage is now company-scoped under companies/{'{'}companyId{'}'}/project-storage/…. The un-prefixed legacy path is kept working, but narrowed to exactly the production company's real id rather than "any signed-in user" as before.

Resolved The "empty adminUsers list means everyone is admin" bootstrap fallback has been removed from the client, the security rules, and the Cloud Functions authorization checks alike — admin status now always requires explicit membership in adminUsers/adminUids.
Previous← Sequence Flows