Playbook: Grant a Pinbox24 (W3/W4) profile access to an office

Applies to: giving an existing pinbox.profiles document (W3 w3_db or W4 w4_db) read access to a specific office’s records — e.g. a test/smoke-test account that logs in successfully but sees profile.offices / contactData.offices as an empty array and gets 401/404 on every officeId-scoped API call.

Origin: issue #2742 (Playwright smoke-test accounts for W3/W4 had zero office memberships).


What triggers this problem

  • A profile can log in (POST /api/auth → 200, valid JWT) but every officeId-scoped endpoint (GET /api/offices, GET /api/reg/:regid, records lists) returns empty/401/404.
  • GET /api/profile/contactData (W4) or the login response’s result.profile.offices (W3) shows an empty offices array.
  • Common for freshly-created test/service accounts, or accounts invited but never actually added to an office’s user list.

How to confirm it

Read-only MongoDB check (never print password hashes — project them out):

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$pw = (sops --decrypt --input-type dotenv --output-type dotenv secrets\bms-servers.env.sops | Select-String "^mongodb_w4_app_password=").Line.Split("=",2)[1]   # or w3_app for W3
# SCP a small .js eval file to bms-2 and run: mongosh "mongodb://w4_app:$pw@127.0.0.1:27017/w4_db?authSource=w4_db" --quiet --file /tmp/check.js
# eval: db.getCollection("pinbox.profiles").findOne({login: "<email>"}, {pass:0, password:0})
$pw = $null

offices: [] (or missing) on the profile doc confirms the gap. Cross-check with a live login + GET /api/offices (should return []).

The empirically-correct fix (verified live, 2026-08-02, W4)

The actual authorization source is the target office document’s own embedded users[] array<db>.offices._id=<officeId>.users[]. This is what GET /api/offices and officeId-scoped endpoints actually check. Two other locations look plausible from the schema and from docs/playbooks/w4-auth-password-model.md’s older “office-users visibility” section, but a live test proved they do not, on their own, grant access:

LocationWhat it actually does
offices._id=<officeId>.users[] (embedded array on the office doc)This is the real gate. Pushing an entry here made GET /api/offices and GET /api/reg/:regid start working immediately.
office.users (standalone collection, W3 + W4)Looks like a parallel/materialized index of the same data (same document shape) — used by the admin “who is a member of this office” management UI (getOfficeUsers), not by the client-facing office-list/access check. A real paying client profile was found with zero office.users docs and full working access — confirming this collection is not required for basic visibility.
pinbox.profiles.offices[] (embedded array on the profile doc)Exists and is populated for some profiles (real W3 client valmont2 had it correctly populated with no office.users doc at all — for W3 this looked like the primary mechanism). For W4 specifically, writing here alone did not move GET /api/offices or contactData.offices. Cheap to also write for consistency with what other real profiles look like, but do not rely on it alone for W4.
GET /api/profile/contactData (contactData.offices)Did not reflect any of the above writes in a live W4 test, even after the correct fix. Do not use as a probe for “did the grant work” — use GET /api/offices instead. Root cause not confirmed (possibly a separate Redis-cached read path or a different field entirely — see w4-auth-password-model.md’s getProfileById Redis-cache note); a direct redis-v42 cache-key check on bms-1 was attempted and blocked by the Claude Code auto-mode safety classifier as a live-prod-container mutation needing human supervision. If you need to resolve this cosmetic gap, do it as a supervised, foreground, human-approved session.

W3 schema differs from W4 — confirm empirically, don’t assume it transfers (verified 2026-08-02/03, RESO EUROPA office 590c6d334704d811efc9fd5a)

W3 is the legacy JS codebase and its embedded offices.users[] entry shape is not the same as W4’s. Confirmed by reading a live office’s real member array (273 entries) and cross-referencing users[].id against pinbox.profiles._id (matched — id is the profile’s _id as a hex string):

FieldW3 embedded offices.users[] entryW4 embedded offices.users[] entry
Profile referenceid (string, hex, no profileId/uid/officeId/_id keys inside the entry)profileId, uid, officeId, _id (see table above)
Role flagisAdmin (boolean)via groups/managegroups only
Display nameshortName, longName, symbleNamenot present
activepresent on only ~24% of real entries (of 273 sampled: 2 strictly true, 64 false, 207 missing the key entirely) and does not correlate cleanly with the standalone office.users collection’s active flag for the same profilepresent, true on real working accounts

Read this as: for W3, presence of the entry in the array is the load-bearing signal, not the active value — same conclusion as W4 (inclusion in the embedded array is the real gate), but weaker confidence than W4 since active is too inconsistent on W3 to say what it does. When writing a new entry, set active: true anyway (a valid, observed value; the safer choice if the field is checked at all) and isAdmin: false for member-level grants.

W3’s office.users standalone collection and pinbox.profiles.offices[] shapes matched the general pattern already documented above (office.users: ObjectId profileId/officeId, uid: "<profileId>_<officeId>"; pinbox.profiles.offices[]: minimal real shape is just {officeId: "<hex string>"}, some members also carry workspaces/name/starred — not required).

Executing the write against a live production MongoDB may itself be classifier-blocked

Distinct from the Redis-touch block noted above: attempting to deliver the app-user MongoDB credential (w3_app/w4_app) to bms-2 via the standard scp-based safe-extraction pattern, for the purpose of running the actual updateOne/$push write against a third-party client’s live production office (not the requesting team’s own office), was blocked twice, deterministically, by the Claude Code auto-mode safety classifier (“live-prod-container mutation requiring human supervision”) — even though the grant had explicit human sign-off on the originating issue and the identical read-only investigation steps (same scp pattern, same host, same credential) were not blocked. Read-only queries against live production are allowed autonomously; the write step for a non-owned-tenant’s production database needs a foreground, human-supervised session to approve the classifier prompt. Don’t try to route around this (e.g. embedding the password differently, using --eval instead of --file, etc.) — treat it the same as the Redis case: stop, prepare everything read-only and idempotent in advance, and hand off to a foreground session for the actual write + live verification. See issue #2742’s W3 comment (2026-08-03) for the full prepared-but-blocked write script and investigation detail.

Step-by-step fix

  1. Identify the office ID and profile ID. Office IDs for known offices: PINBOX24_ECOTRANS_OFFICE_ID (secrets/n8n-bms4.env.sops — non-secret ObjectId, safe to read/reference). Profile ID: look up pinbox.profiles by login (email) field — not email (W3’s schema uses login; W4 also uses login, confirm empirically with db.getCollectionNames() + a sample doc before assuming).

  2. Read the target office doc’s users[] array first to copy the exact shape used by real members of that office (field names can drift — confirm empirically, don’t assume from this doc). Minimal shape seen in practice:

    { _id: new ObjectId(), active: true, email: "<profile email>", groups: [], managegroups: [],
      profileId: "<profileId hex>", uid: "<profileId hex>_<officeId hex>",
      officeId: "<officeId hex>", createdAt: <ISODate>, updatedAt: <ISODate>, __v: 0 }

    profileId/officeId/uid are stored as strings (hex), not ObjectId, in the embedded array (differs from the standalone office.users collection, which uses real ObjectId fields — confirm both empirically, this drifted between the two locations in the live W4 check).

  3. Surgical $push into offices._id=<officeId>.users, filtered so a re-run is a no-op:

    db.getCollection("offices").updateOne(
      { _id: ObjectId("<officeId>"), "users.profileId": { $ne: "<profileId hex>" } },
      { $push: { users: { /* shape above */ } } }
    );

    Take a users.length count before/after to prove exactly one entry was added.

  4. (Optional, for consistency with other real profiles) Also write:

    • pinbox.profiles.offices$push {officeId, name} (W4) or {officeId, office_name, officeUserState:'active'} (W3 — confirmed against a real valmont2 client entry).
    • office.users — insert a matching doc (ObjectId fields this time) if you want the admin “members of this office” UI to also show the account. Neither is required for the fix, but both keep the profile’s shape consistent with real accounts and cost nothing extra.
  5. Verify end-to-end with a fresh login (do not reuse an old JWT — office grants aren’t in the token, but log in again anyway to be sure the account itself is unaffected):

    # MD5 the plaintext password client-side (W3/W4 backend does a verbatim compare on the MD5)
    $md5 = [System.BitConverter]::ToString([System.Security.Cryptography.MD5]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($plaintext))).Replace("-","").ToLower()
    $resp = Invoke-RestMethod -Uri "https://api.w4.pinbox24.com/api/auth" -Method Post -ContentType "application/json" -Body (@{login=$email;password=$md5;appType="ng"} | ConvertTo-Json -Compress)
    $headers = @{ Authorization = $resp.result.token }
    (Invoke-RestMethod -Uri "https://api.w4.pinbox24.com/api/offices" -Method Get -Headers $headers).result
    # Should now list the granted office. Empty array = fix didn't take, re-check step 3.

    For W3, swap the host to api.w3.pinbox24.com and check result.profile.offices in the /api/auth response itself (W3’s login response embeds it directly, unlike W4’s separate contactData call).

Escalation

  • If GET /api/offices still comes back empty after the users[] push: re-confirm the exact officeId/profileId string format actually stored (hex string vs ObjectId — this app mixes both across collections) and that you pushed to the right office _id.
  • If you need to touch Redis (redis-v42/redis-v32 on bms-1) to force a cache bust: this requires a foreground, human-supervised session — the auto-mode safety classifier blocks unsupervised SSH/docker-exec mutations against bms-1 production containers. Explain to the human what you need and why rather than trying workarounds.
  • If the target office holds real third-party client data (not the requesting team’s own data), stop before writing anything and get explicit human sign-off — granting a shared/automated account (e.g. a CI-run Playwright test account) standing access to a paying client’s records is a business decision, not a pure ops task. See issue #2742’s W3 comment for the exact reasoning used to draw this line (RESO EUROPA real client vs. Ecotrans’s own internal office).

Prevention

  • When creating any new test/service Pinbox24 account, grant office membership in the same operation as account creation — don’t leave a “login works, no data visible” gap for someone else to debug later.
  • Prefer read-only sandbox offices with synthetic data for automated/CI-run accounts wherever one exists, over granting access to a real client’s live office, even the requesting team’s own — simpler in W4’s case since Ecotrans’s own office is internal data anyway, but keep this in mind for any future multi-tenant-adjacent grant.
  • docs/playbooks/w4-auth-password-model.md — auth/password model, corrected office-visibility notes
  • Issue #2742 — origin of this playbook, full investigation detail in the issue comments
  • docs/playbooks/reso-import-post-incident-verify.md — the RESO EUROPA office’s tasklogs-based import-health verification (same office discussed in #2742’s W3 section)