Plan #5565 — Pinbox24 W4 authentication remediation
Status: Code-change-design v1 (ready for /review-plan)
Owner-issue: #5565 — [SECURITY][Plan] Pinbox24 W4 auth remediation
Branch: plan/5565-pinbox24-w4-auth-remediation
Target repo of the eventual fix: gitlab.com/pinbox24/p24-back-ts (development branch) — a Pinbox24-owned repo. p24-infra can push branches + open MRs but cannot merge (docs/pinbox24/gitlab-repo-locations.md §Known limitation).
Found via: #3826 / PR #5559 (W4 user/office/workspace provisioning playbook).
PLAN ONLY — do NOT implement. The deliverable is this document. No GitLab MR, no MongoDB writes, no source edits until this plan is
/review-plan-approved and the human decisions in §8 are made. Authored by a dev-coder worker: writing the design is in scope; every credential, server, DB and GitLab-MR action it enumerates is delegated / human-gated — see §9. Role recorded per worker-issue-scoped Step R4:Role: dev-coder.
0. Read this first — a source-vs-deployed drift that reframes Finding #1
The issue’s Finding #1 (from reading p24-back-ts development-branch source) states the main
signup path stores the registration password in plaintext and login does plain string
equality. p24-infra’s own deployed-build ground-truth
(docs/playbooks/w4-auth-password-model.md, traced inside the running v42-prod container on
bms-1) states something adjacent but materially different:
| Aspect | Finding #1 (source, development) | w4-auth-password-model.md (deployed dist/, bms-1) |
|---|---|---|
Stored value in pinbox.profiles.pass | ”the registration password directly … no hashing” (auth.controller.ts:96-102) | md5(plaintext), lowercase hex, unsalted |
| Login compare | plain string equality (auth.helper.ts:51) | assert.equal(profile.pass, req.body.password) — verbatim |
| Who produces the hash | (implied none) | the Angular frontend hashes client-side before POST |
Both agree the backend performs no server-side hashing. They disagree on what the string actually is: true plaintext, or a client-supplied MD5. This distinction changes the entire migration design and must be resolved before any code is written (Gate G1, §7). Two cases:
- Case A — server receives client-side
md5(plaintext)(what the deployed model says). The server never sees the real plaintext, so it cannot bcrypt-the-plaintext as the issue’s suggested shape assumes. It can onlybcrypt(md5_from_client). Still a real win (a DB dump no longer yields the password-equivalent MD5 in the clear; MD5 is unsalted+fast, bcrypt is salted+slow), but the transport/at-rest-in-transit value is still a password-equivalent — a client-side concern out of scope for a backend MR (§10 residual risk). - Case B — server receives true plaintext (what the literal source reading implies, if the deployed frontend for this path does not pre-hash). Then the issue’s original bcrypt-the-plaintext shape is correct as-is.
The migration in §2 is written to be correct in both cases by keying every decision on the
shape of the value the server actually receives and stores, never on an assumption about whether
it is “plaintext.” G1 (§7) is the go/no-go: clone development, read auth.controller.ts +
auth.helper.ts + the frontend submit path, and diff against the deployed dist/ on bms-1
(read-only) to confirm which case is live on the currently-deployed build — the same
deployed-vs-source discipline the issue demands for Finding #4.
This drift is not hypothetical for this codebase:
w4-auth-password-model.mdandpinbox24-office-membership-grant.mdalready document three prior incidents (#4135/#4144/#4150) that burned time by assuming the W4 password format. Do not repeat that — verify at G1.
1. Findings under remediation (verbatim from the issue, with source citations)
| # | Finding | Cited location (p24-back-ts development) | Severity |
|---|---|---|---|
| 1 | Password stored without server-side hashing on the primary signup path; login = verbatim compare | auth.controller.ts:96-102, auth.helper.ts:51 | Critical (see §0 for the plaintext-vs-client-MD5 nuance) |
| 1b | Two narrower system-generated-password paths already MD5 server-side (context, not a bug per se) | pinboxProfile.controller.ts:422-424, pinboxProfile.helper.ts:270-280 | Informational |
| 2 | Invitation accept/reject has no caller-identity check (anyone can accept/reject any invite) | acceptUserInvitation.helper.ts | High (auth bypass / IDOR) |
| 3 | Likely bug: isOfficeUser: false set on the office.users doc created post-accept (probably should be true) | acceptUserInvitation.helper.ts:105-114 | Medium (needs blast-radius check) |
| 4 | Deployed-vs-source drift: office-doc embedded users[] write is commented-out dead code in source, yet live testing proved that same array is the real access gate | office.helper.ts:372-386 vs docs/playbooks/pinbox24-office-membership-grant.md | Medium (verification-gated) |
Hard constraint (from the issue, applies to all password work): the fix MUST NOT force-invalidate, force-reset, or lock out any existing user’s current password. No mass rehash/reset. Real Pinbox24 customers log in daily.
2. Deliverable 1 — Password migration: lazy rehash-on-successful-login
2.1 Design shape (adapted for the §0 drift)
Principle: the stored representation upgrades exactly once, transparently, on the next successful login; the value the user types never changes and they never notice. New writes are strong from day one.
Let submitted = the value the server receives in req.body.password (per §0/G1: either
md5(plaintext) in Case A, or true plaintext in Case B). Let stored = pinbox.profiles.pass.
Login path (auth.helper.ts login, currently auth.helper.ts:51):
1. Load profile by { login }.
2. Classify `stored` by shape (see 2.2):
- looks-like-bcrypt ($2a$/$2b$/$2y$, 60 chars) -> MIGRATED
- otherwise (32-hex MD5, or arbitrary plaintext) -> LEGACY
3. If MIGRATED: ok = bcrypt.compare(submitted, stored)
If LEGACY: ok = constant-time-equal(submitted, stored) // preserves today's verbatim compare
4. If !ok: reuse the EXISTING wrongPassCount/wrongPassTime lockout unchanged. Return 401. Do NOT
leak which branch failed (uniform error + uniform-ish timing; see §10).
5. If ok AND LEGACY: stored := bcrypt(submitted); persist to pinbox.profiles.pass in the SAME
request (best-effort — a write failure must NOT fail the login; log + metric, retry next login).
6. Issue JWT exactly as today.
Every write path must hash-at-write (no new legacy values from merge-day forward):
| Write path | File:line (source) | Change |
|---|---|---|
| Primary registration | auth.controller.ts:96-102 | replace the raw pass write with bcrypt(submitted) |
Password reset (/api/auth/reset/:token) | auth.helper.ts reset flow (see w4-auth-password-model.md §Reset) — stores confirmnewpassword verbatim | wrap in bcrypt(...) |
| Admin/system-generated #1 | pinboxProfile.controller.ts:422-424 | after its md5(...), wrap bcrypt(md5(...)) (or bcrypt the source value — decide at G1 per the case, keep the client contract intact) |
| Admin/system-generated #2 | pinboxProfile.helper.ts:270-280 | same as above |
Any other pass writer | grep at G1 (grep -rn "\.pass\s*=" src/) | enumerate ALL and convert — a single missed writer reintroduces a legacy value |
Critical contract preservation: whatever transform the client applies before POST must be left untouched. If the client sends
md5(plaintext)(Case A), the server storesbcrypt(md5(plaintext))and at login comparesbcrypt.compare(md5_from_client, stored). We wrap the received value; we never require the client to send something new. This is what keeps every currently-active password valid (the issue’s hard constraint) — the migration is invisible to the client and to the user.
2.2 The “already-migrated vs legacy” marker — is it reliable?
The issue asks to verify distinguishability is real before relying on it. It is reliable, for a concrete reason:
- bcrypt output is always
$2[aby]$<cost>$<22-char-salt><31-char-hash>— 60 chars, always starts with the literal$2. bcrypt libraries (bcrypt,bcryptjs) reject a non-$2string atcompare(). - Legacy MD5 is exactly 32 lowercase hex chars, no
$. - Legacy plaintext is arbitrary but, per the deployed model, in practice is a 32-hex MD5; even
if a true plaintext existed it cannot begin with
$2$+ valid bcrypt structure by accident.
So stored.startsWith('$2') (optionally bcrypt.getRounds() succeeds) is a robust MIGRATED test.
No new schema field is required — bcrypt’s own prefix is the format marker. (A dedicated
passVersion/passAlgo field is an optional belt-and-suspenders; §8 decision D1. Recommendation:
do not add one — it is redundant with the prefix and adds a second thing to keep in sync.)
- Verify at G1: sample (read-only) the live
pinbox.profiles.passdistribution on bms-1 to confirm every existing value is 32-hex (or catalogue any outliers) before relying on “non-$2== legacy”. Command shape (read-only, viaw4_app, project out nothing sensitive since we only read lengths/prefixes, never print a full hash):db.getCollection("pinbox.profiles").aggregate([{$project:{l:{$strLenCP:"$pass"},p:{$substrCP:["$pass",0,2]}}},{$group:{_id:{l:"$l",p:"$p"},n:{$sum:1}}}])— expect{l:32, p:<hex>}dominating. Any$2-prefixed rows already exist = someone migrated; any non-32 non-$2rows = true-plaintext outliers to handle explicitly.
2.3 Hashing library choice
- G1 must determine what
p24-back-tsalready depends on. Readpackage.json/package-lock.jsonondevelopment. Ifbcryptorbcryptjsis already present (common in Node/TS backends), use it — no new dependency, no supply-chain review. - If neither is present: recommend
bcryptjs(pure-JS, no native build step — safest for a container/PM2 deploy where nativebcryptcan fail to compile on the target Node/glibc), cost factor 12 (tune at G1 against p24-back-ts’s per-request latency budget; W4 login already does a lockout check so it is not a hot inner loop). Justify the added dependency in the MR. argon2is stronger but pulls a native addon — higher deploy risk on this PM2/container stack; not recommended unless already a dependency.
2.4 Dormant accounts (never-log-in-again residual)
Accounts that never log in during any observation window stay on the legacy representation indefinitely — this is inherent to lazy migration and the issue calls it out.
- In scope for this plan: document the residual, and expose a count metric so the tail is
observable (see §6.3):
w4_profiles_legacy_pass_total(count of non-$2passvalues), scraped read-only. When it plateaus, the remaining accounts are effectively dormant. - Out of scope (recommend deferring to a follow-up issue): a time-boxed, opt-in soft email nudge (“we’ve upgraded security, next login refreshes your protection”) — never a mass forced-reset or lockout (violates the hard constraint). Decision D2 (§8). Recommendation: defer; ship lazy migration first, watch the metric for N weeks, then decide with real numbers.
3. Deliverable 2 — Invitation accept/reject caller-identity check
Finding: acceptUserInvitation.helper.ts accept/reject does not verify the authenticated
caller’s identity against the invitation’s invitedToEmail, so any authenticated (or possibly
unauthenticated — verify at G2) caller can accept/reject any invitation → privilege escalation /
IDOR.
3.1 Design
-
G2 — locate the auth context available in this code path (must verify at source before writing the check — the plan cannot assume the middleware shape):
- Trace how
acceptUserInvitationis routed. In this codebase login attaches identity via the JWT (auth.helper.loginissuesresult.token; officeId-scoped endpoints already gate on it). Find the middleware that decodes the JWT and where it deposits the caller (req.user,req.profile,res.locals, actx, …). Confirm the decoded identity exposes the caller’s email/login(the field that can be compared toinvitedToEmail) or aprofileIdthat resolves to it. - If the accept/reject route is currently unauthenticated (no JWT middleware at all), the fix is larger: it must be moved behind the auth middleware first. Flag this at G2 — it changes the MR’s blast radius (any legitimate caller that relied on calling it token-less breaks).
- Trace how
-
The check (pseudocode, exact field names resolved at G2):
const caller = <decoded JWT identity from G2>; // e.g. req.user.login / req.user.email
const invite = await Invitation.findById(invitationId);
if (!invite) return 404;
if (normalizeEmail(caller.email) !== normalizeEmail(invite.invitedToEmail)) {
return 403; // not your invitation
}
// ... existing accept/reject logic unchanged ...
normalizeEmail= trim + lowercase (W4 stores login/email case-sensitively perw4-auth-password-model.md; confirm at G2 whether invites storeinvitedToEmailalready normalized — if the compare is case/whitespace-sensitive on live data it will 403 legitimate users; this is a real regression risk, verify).- Apply the identical check to both accept and reject.
- Regression risk: any existing legitimate flow that accepts an invite on behalf of another user (e.g. an admin bulk-accept) would now 403. G2 must grep for all callers of the accept/reject endpoint (frontend + any server-to-server / automation) and confirm none rely on cross-identity accept. If one does, the check needs an explicit admin-role bypass (decision D3, §8).
4. Deliverable 3 — isOfficeUser default bug + blast-radius
Finding: acceptUserInvitation.helper.ts:105-114 writes isOfficeUser: false on the
office.users doc it creates post-accept; almost certainly should be true.
4.1 Blast-radius check design (G3 — do this BEFORE flipping anything)
The value must not be flipped blind — something may already depend on the current false.
- Grep every reader across
p24-back-tsdevelopment:grep -rn "isOfficeUser" src/— enumerate every read, every query filter ({ isOfficeUser: true }/$eq/$ne), every place it gates behaviour (UI role, permission, list membership, notification target, billing seat count …). - Also grep the frontend repos —
pinbox24-version-4(gitlab.com/pinbox24/pinbox24-version-4, Angular, pergitlab-repo-locations.md) — forisOfficeUser, since the flag may drive UI gating client-side even if the backend ignores it. - Classify each reader as: (a) expects
truefor a real office member (confirms the bug), or (b) relies onfalse(flipping would break it). Only proceed to flip if no class-(b) reader exists, or each class-(b) reader is reconciled.
4.2 Fix + backfill decision
- Code fix: set
isOfficeUser: trueat creation inacceptUserInvitation.helper.ts:105-114(assuming G3 confirms it). - Already-created wrong docs — decision D4 (§8). Two options:
- Backfill migration (recommended if G3 shows readers actually expect
true): a one-shot, idempotent, read-only-until-approved MongoDB update, human-supervised (perpinbox24-office-membership-grant.md, productionoffice.userswrites on a non-owned tenant are classifier-blocked and need a foreground session):db.getCollection("office.users").updateMany({ isOfficeUser: false, /* + any G3-derived guard so we only touch invite-created docs */ }, { $set: { isOfficeUser: true } })— with a before/after count to prove the exact number changed, and a documented rollback ($setback tofalseon the same guarded set). - Leave-as-is, fix forward only (recommended if G3 shows nothing meaningfully reads it, or the
existing
falsedocs are harmless): document that historical docs keep the old value; only new accepts gettrue.
- Backfill migration (recommended if G3 shows readers actually expect
- Do not run a blanket
updateMany({}, ...)— always guard to the invite-created subset G3 identifies, so unrelatedoffice.usersdocs (some legitimatelyfalse?) are untouched.
5. Deliverable 4 — Office-membership drift reconciliation (verification gate, no code yet)
The conflict: source (office.helper.ts:372-386) has the office-doc embedded users[] write
commented out (dead code) on development; but docs/playbooks/pinbox24-office-membership-grant.md
docs/playbooks/w4-auth-password-model.md §office-usersproved empirically (live, 2026-08-02) that the office document’s embeddedusers[]array is the real access gate — pushing an entry there is what makesGET /api/officesandofficeId-scoped calls start working.
Interpretation: the deployed production build almost certainly is not the development
source at that line — either a different branch/build is deployed, or a host-mounted persistent
patch re-enables the embedded write at container-run time (the exact “persistent-patches gotcha”
documented for the W3/W4 stack and the s3-v2-v42-prod service in
docs/pinbox24/gitlab-repo-locations.md and docs/w3-w4-stack-operations.md).
5.1 Live-verification step (G4) — REQUIRED before any office-membership code ships
Mirror how gitlab-repo-locations.md ground-truthed s3-v2-v42-prod (matched running container’s
compiled dist/ against candidate source):
- On bms-1, read-only, identify the deployed W4 backend build:
docker ps→ the W4 API container;pm2 show <app>→ the running script path under/app/dist. - Read the deployed compiled
office.helper.js(or equivalent) in thatdist/and determine whether the embeddedoffices.users[]write is live (present/executed) in production. - Check for host-mounted persistent patches (
/root/...mounts) that override the office helper at run time — same class as the fours3v2-prodpatch files. - Diff the deployed build’s git provenance against
developmentHEAD and against any other candidate branch (a build tag / commit stamped in the image, if present). - Record the reconciliation in
pinbox24-office-membership-grant.md(append a “source vs deployed reconciled” note) and in this plan’s issue thread.
Decision gate: do not touch, re-enable, remove, or “clean up” the office.helper.ts:372-386
embedded write until G4 says which mechanism is actually live. If the embedded write is load-bearing
in production, “removing dead code” would break office access for real clients. This deliverable
ships no code in this plan — it produces a verified fact + a decision (D5, §8) for a separate
implementation issue.
Ownership: the G4 live inspection on bms-1 is a sys-admin / infra-task action (SSH + docker/pm2 on production), not dev-coder — delegated per §9. dev-coder consumes the result.
6. Deliverable 5 — Rollout plan (human review + post-deploy verification)
Because the MR targets a Pinbox24-owned GitLab repo p24-infra cannot merge
(gitlab-repo-locations.md §Known limitation), the rollout is explicitly human-gated.
6.1 What must be human-reviewed/approved BEFORE any MR is opened
- This plan
/review-plan-approved and decisions D1–D6 (§8) made. - Gates G1–G4 (§7) all executed and their findings appended to the issue.
- Explicit human sign-off to touch production auth code on a third-party-owned repo (business decision — the code guards paying customers’ logins).
- For any MongoDB backfill (D4) or office-membership change (D5): a separate, foreground,
human-supervised session — production writes on non-owned tenants are classifier-blocked
(
pinbox24-office-membership-grant.md) and must not be attempted from an autonomous worker.
6.2 MR sequencing (recommended: smallest-blast-radius first, independent MRs)
Split into independent MRs so each can be reviewed/reverted alone — do not bundle the auth bypass fix behind the risky password migration:
| Order | MR | Risk | Rationale |
|---|---|---|---|
| 1 | Invitation caller-identity check (§3) | Low-Med | Pure authz add; smallest, highest-value security win; no data migration |
| 2 | isOfficeUser code fix (§4, code only) | Low | One-line default flip once G3 clears it; backfill handled separately + supervised |
| 3 | Password hash-at-write on all writers (§2.1 table) | Med | Stops new legacy values; no read-path change yet, so zero login-behaviour change |
| 4 | Lazy rehash-on-login (§2.1 login path) | High | Touches the hot login path for every user; ship last, behind a feature flag if the repo supports one, with the §6.3 metrics live first |
| — | Office-membership change (§5) | Gated | Not in this plan — separate issue after G4 |
| — | isOfficeUser backfill (D4) | Gated | Separate supervised MongoDB session, not an MR |
MRs 3 and 4 can be one MR if reviewers prefer, but 4 is the only one that can lock out a live user if wrong — keeping it separable is safer.
6.3 Post-deploy verification (what a human at Pinbox24/p24-infra checks)
- Before each MR merges: confirm a real login still works end-to-end. Use the read-only
round-trip from
w4-auth-password-model.md §Verify:POST https://api.w4.pinbox24.com/api/authwith a known account, expect200+ JWT. - After MR 3 (hash-at-write): register a fresh test account (or reset one), then read its
pass(read-only, length/prefix only) — must now be$2.... Existing accounts still200on login (unchanged read path). - After MR 4 (rehash-on-login): log in a legacy test account →
200; re-read itspass→ now$2...(migrated). Log it in again → still200(bcrypt.compare path). Log in a wrong-password →401and lockout counter still increments. Confirm thew4_profiles_legacy_pass_totalmetric decrements by one after the successful legacy login. - Standing observability: the two metrics (
w4_profiles_legacy_pass_total,w4_login_rehash_failures_total) — expose via the existing W4 monitoring approach (docs/plans/plan-4132-w3-w4-monitoring-strategy.md); a persistent non-zero rehash-failure count means the in-request re-store write is failing and users are re-migrating every login (perf + correctness smell). Wire to the standard Discord/GH-issue error path per CLAUDE.md §Error Notification.
7. Verification gates — the go/no-go checklist (all read-only; do before writing any fix)
| Gate | What it establishes | How | Owner |
|---|---|---|---|
| G1 | Which §0 case is live (client-MD5 vs plaintext); every pass writer; the hashing lib already in package.json; the live pass-shape distribution | Clone p24-back-ts development (read-only) + read deployed dist/ on bms-1 + read-only Mongo aggregate on pinbox.profiles.pass shapes | dev-coder (source) + sys-admin (bms-1 dist/ + Mongo read) |
| G2 | Where the caller’s authenticated identity is available on the accept/reject path; whether that route is currently authenticated at all; all callers of accept/reject | Source trace in p24-back-ts + frontend grep | dev-coder |
| G3 | Every reader of isOfficeUser (backend + frontend); does anything rely on false | grep -rn isOfficeUser across p24-back-ts + pinbox24-version-4 | dev-coder |
| G4 | Whether the office-doc embedded users[] write is live in the deployed build (source says dead) | Deployed dist/ + persistent-patch mounts on bms-1, per §5.1 | sys-admin / infra-task |
No fix MR is opened until the gate(s) feeding it are green. G1→MR3/MR4; G2→MR1; G3→MR2 (+D4); G4→the deferred office-membership issue (D5).
dev-coder scope note: G1’s source clone requires
GITLAB_ADMIN_PAT(secrets/administration.env.sops). Decrypting a SOPS value is outside dev-coder’s role (cannot: decrypt values from any secrets/*.env.sops file). At implementation time the clone step is either (a) run by the secret-manager/human who holds the age key, or (b) done in a session with the PAT already provisioned to the worker env — not by decrypting SOPS inline in a dev-coder session. This is why G1/G4 are written as gates, not steps this plan executes.
8. Human decisions required (resolve during/after /review-plan)
| # | Decision | Recommendation |
|---|---|---|
| D1 | Add an explicit passAlgo/passVersion field, or rely on the bcrypt $2 prefix as the marker? | Rely on the prefix — reliable (§2.2), no schema change, nothing extra to sync |
| D2 | Dormant-account soft email nudge — in scope now, or defer? | Defer to a follow-up issue; ship lazy migration + metric first, decide with real numbers. Never a forced mass reset |
| D3 | Does any legitimate flow accept an invite on behalf of another user (needs admin bypass in §3)? | Determine at G2; default no bypass unless G2 finds a real caller |
| D4 | isOfficeUser historical docs: backfill vs fix-forward-only? | Decide from G3: backfill (guarded, supervised) if readers expect true; fix-forward if nothing meaningfully reads it |
| D5 | Office-membership: after G4, re-enable/keep the embedded users[] write, or leave the deployed mechanism untouched? | Separate issue — do nothing until G4; default is “match whatever is live in prod, change nothing that breaks access” |
| D6 | Password hashing library + cost factor | bcryptjs cost 12 if none present; reuse whatever G1 finds already in package.json |
9. Role & delegation boundaries (worker-issue-scoped Step R2/R3)
This plan was authored by a dev-coder worker. In-scope here: reading docs, reasoning about the
GitLab-cloned source, and writing this plan doc in p24-infra. Everything the plan enumerates for
implementation that falls outside dev-coder is explicitly delegated, not performed:
- SOPS decrypt of
GITLAB_ADMIN_PAT(to clone for G1–G3) →delegate_to.credentials: secret-manager/ human age-key holder. dev-coder never decrypts SOPS values. - bms-1 SSH + docker/pm2 + production MongoDB reads/writes (G1 dist/, G4, D4 backfill,
post-deploy verification) →
delegate_to.server-operations: sys-admin(via theinfra-taskworker on bms-4). Production DB writes on a non-owned tenant additionally require a foreground, human-supervised session (classifier-blocked otherwise —pinbox24-office-membership-grant.md). - Opening the GitLab MR is implementation, gated on this plan’s approval — not done now (the issue’s own scope: “Do NOT open a GitLab MR yet”).
No credential value appears anywhere in this plan — key names only (GITLAB_ADMIN_PAT,
mongodb_w4_app_password, SUPABASE_SERVICE_ROLE_KEY).
10. Residual risks (documented, some out of scope for a backend MR)
- Client-side MD5 is a password-equivalent in transit/at-rest-on-client (Case A). bcrypt at the DB defeats a DB-dump attack but not a TLS-MITM or compromised-frontend attack, because the value the client sends is itself sufficient to log in. A true fix (send plaintext over TLS, hash only server-side, or add a challenge) is a frontend + backend contract change — out of scope for this remediation; flag as a follow-up security issue. Confirm the case at G1.
- Dormant accounts never migrate (§2.4) — accepted, observable via metric.
- Login timing/oracle: the bcrypt vs verbatim branches have different timing; keep the 401 and lockout identical across branches and avoid branch-revealing error text (§2.1 step 4). A perfect constant-time login is out of scope; note it.
- Rehash write failure mid-login must be non-fatal (login still succeeds); monitored via
w4_login_rehash_failures_total. - Deployed-vs-source drift may exist on the auth path too (§0) — the whole plan is gated on G1
precisely because a
development-source reading may not match the deployed build.
11. Compliance
- New service/container: none — this modifies an existing third-party backend. No
dev_r_servicesrow change in p24-infra. - EU AI Act: not applicable — no AI system, scoring, or automated employment decision is added (dev-coder Startup EU AI Act trigger check: none of the three triggers apply).
- Secrets: no new secret; no value displayed; SOPS untouched by this plan.
- Error notification: the two monitoring metrics (§6.3) wire into the standard Discord + GH-issue error path per CLAUDE.md §Error Notification when the rehash-failure count is non-zero.
12. Related
docs/pinbox24/gitlab-repo-locations.md— GitLab access + repo map + persistent-patches gotchadocs/playbooks/w4-auth-password-model.md— deployed W4 password model (client-side MD5, verbatim compare)docs/playbooks/pinbox24-office-membership-grant.md— empirical office-access gate (Finding #4 conflict source)docs/w3-w4-stack-operations.md— W3/W4 ownership + permission matrix + escalationdocs/playbooks/pinbox24-w4-user-office-workspace-provisioning.md— the #3826 / PR #5559 playbook this came fromdocs/plans/plan-4132-w3-w4-monitoring-strategy.md— W4 monitoring approach for §6.3 metrics- Issues: #5565 (this), #3826 (origin), #2742 (office-membership empirical finding), 4135 (W4 password-format incidents)
Authored by a p24-infra dev-coder worker (bms4-cw-1). Plan only — implementation gated on /review-plan + §8 decisions + §7 gates.