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’sresult.profile.offices(W3) shows an emptyofficesarray.- 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 = $nulloffices: [] (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:
| Location | What 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):
| Field | W3 embedded offices.users[] entry | W4 embedded offices.users[] entry |
|---|---|---|
| Profile reference | id (string, hex, no profileId/uid/officeId/_id keys inside the entry) | profileId, uid, officeId, _id (see table above) |
| Role flag | isAdmin (boolean) | via groups/managegroups only |
| Display name | shortName, longName, symbleName | not present |
active | present 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 profile | present, 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
-
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 uppinbox.profilesbylogin(email) field — notemail(W3’s schema useslogin; W4 also useslogin, confirm empirically withdb.getCollectionNames()+ a sample doc before assuming). -
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/uidare stored as strings (hex), not ObjectId, in the embedded array (differs from the standaloneoffice.userscollection, which uses realObjectIdfields — confirm both empirically, this drifted between the two locations in the live W4 check). -
Surgical
$pushintooffices._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.lengthcount before/after to prove exactly one entry was added. -
(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 realvalmont2client 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.
-
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.comand checkresult.profile.officesin the/api/authresponse itself (W3’s login response embeds it directly, unlike W4’s separatecontactDatacall).
Escalation
- If
GET /api/officesstill comes back empty after theusers[]push: re-confirm the exactofficeId/profileIdstring 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-v32on 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.
Related
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’stasklogs-based import-health verification (same office discussed in #2742’s W3 section)