Skip to content

Impersonating a user who must change their password strands the admin on that user's change-password screen #24762

Closed

Description

@objectstack-fleet

Ruled: 6288774214 · letter E · 2026-10-20T10:23Z

Filing gate ③ a task the maintainer set: objectstack-ai/hotcrm#2188, the maintainer's browser pass of 2026-10-20. Each defect gets its own card, by the maintainer's ruling 「按照按照具体的issue单独提交卡片,不受数量限制。」 (recorded on objectstack-ai/hotcrm#2188, 6283467378). This is finding F-12 of track 2 (Setup as the system administrator), by the cloud tester session_01VuXyJvhGKJy8qeUvhKyxzv. It was measured on HotCRM main f22dfa52 with @objectstack/* 18.0.0-next.5, in Chromium at 1440×900 on a locally booted, seeded install. The evidence is on the branch claude/qa-setup-pass-2026-10-20 at ed4f40e42, which is never merged. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.

Reader (who acts): objectstack triage routes it.

The finding, as measured

  • Persona / URL: a separate dev-admin session; Users → qa.plain → Impersonate User → Continue (qa.plain had "Require Password Change On Next Login", the default of Set Password).
  • Expected: an impersonation session the admin can use and end.
  • Actual: POST /api/v1/auth/admin/impersonate-user 200 and "Now impersonating user", then a full-page "You must change your password — Current password / New password" form for the target's password, and 403 toasts ("Saved views for sys_user could not be loaded … PASSWORD_RESET_REQUIRED"). The only way out is "Sign out instead"; no "stop impersonating".
  • Evidence: 07-people-users-impersonating.png.
  • class: a. Reach: support admins impersonating new or reset users.
  • Suspected landing: objectstack plugin-auth (password-reset gate applied to impersonation sessions) / console.
  • Dedupe words: impersonate password reset required, impersonation dead end.

Seat review

  • Landing (seat review): plugin-auth's must-change-password gate applies to an impersonation session. The objectui overlay comes from objectui#12365.

Dedupe

Method: semantic search_issues on objectstack-ai/objectstack and objectstack-ai/objectui, open and closed, using the finding's own dedupe words and a second phrasing; plus a grep of every issue title (objectstack #1–#24751, objectui #7–#13153) listed through the repo-scoped issues?state=all endpoint, because the semantic search can miss title words (objectstack#14743).


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-20T09:12Z
    Session: session_01UmqzD97e14iws6hmVhgZ5y
    Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24762-impersonation-reset-gate (a readings-only dispatch: no edit, no PR; the readings are posted to this card)
    Worktree: none (readings on origin/main from the shared checkout, read-only)
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: none written. Read: packages/plugins/plugin-auth/src/** (the forced password-reset gate, PASSWORD_RESET_REQUIRED, and the impersonation path admin-impersonate-endpoint.ts), and objectui's change-password overlay at the console pin, for the options only (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus (TIER_DEFAULT; a readings-only dispatch whose output is a decision analysis)
    Clause-②: no
    Responsibility: plugin-auth's forced password-reset gate binds an impersonation session to the impersonated user's pending reset | none known: the only exit today is "Sign out instead" | support admins impersonating a new or reset user; measured on a HotCRM install 2026-10-20 (card)
    Thread-read: none
    Serial constraints cleared: nothing is written. The in-flight #24773 (this seat) edits plugin-auth/src/auth-plugin.ts's OAuth register wrapper only.

    The fix moves when an authentication gate applies to an impersonation session, so it sits on the human floor (lanes/services.md). This dispatch measures the gate and analyses the options. The seat then files the decision on this card.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24762,
      "status": "reading",
      "branch": "claude/issue-24762-impersonation-reset-gate",
      "pr": null,
      "session": "session_01UmqzD97e14iws6hmVhgZ5y (mode:subagent: the dispatching PM's session id)",
      "premise_still_valid": true,
      "summary": "Readings only, on objectstack origin/main 08d201a80e and objectui 12dc7b47c3 (the .objectui-sha console pin at that main). The forced-reset gate keys on the session's user id alone (customSession, then computeAuthGate, then resolveUserAuthGates reading sys_user.must_change_password), and an impersonation session's user is the target, so the target's posture binds the admin's impersonation session; the session row does carry impersonatedBy, and the gate never reads it. The server exit (/admin/stop-impersonating) is deliberately unheld and reachable by reading, but the console's full-screen RemediationOverlay (z-[200]) covers the ImpersonationBanner (z-50) that holds the 'Stop impersonating' button, and the overlay itself offers only the target's change-password form and 'Sign out instead'. Five options are analysed on the four axes; the recommendation is E = C (a console exit) + B1 (credential-scope postures do not bind an impersonation session), or C alone if the maintainer declines the relaxation; A is not recommended.",
      "readings": [
        "R1 THE GATE. Producer: plugin-auth customSession stamps user.authGate = computeAuthGate(user.id, session.activeOrganizationId) (packages/plugins/plugin-auth/src/auth-manager.ts:4494, 4589-4596). computeAuthGate returns the first entry of resolveUserAuthGates(userId, organizationId) (:8890-8903), which reads sys_user BY THAT ID under a system context (:9004-9007) and pushes PASSWORD_RESET_REQUIRED when must_change_password is true (:9019-9030), then PASSWORD_EXPIRED (:9032-9042), then MFA_REQUIRED. It keys on the session's user id (plus the active org, for MFA) and on nothing else: it never reads session.impersonatedBy. Its docblock states the premise: 'Every scope holds a session, because a session IS the password sign-in (#23287, ruling C' item 1)' (:8893-8894). Doors: every transport seam reads the session's user.authGate and answers 403 with its code unless the path is allow-listed, e.g. the dispatcher (packages/runtime/src/http-dispatcher.ts:1564-1566) and REST (packages/rest/src/rest-server.ts:2388-2410); git grep finds 13 evaluateAuthGate call sites (storage, settings, datasource admin, print, cloud-connection, mcp, webhooks, endpoint policy, platform-admin judge). The allow-list (packages/core/src/security/auth-gate.ts:193-200 and 363-379) is the /auth/* namespace, /health, /ready, /discovery, /me/apps, /me/localization, /approvals/act and the inbound-hook POST. So data reads, saved views (the card's toast), metadata and files all answer 403 PASSWORD_RESET_REQUIRED. Declared scope: credential, 'password sign-in alone: the session is held, and the user's API keys keep working' (packages/spec/src/kernel/execution-context.zod.ts:49-50, table at :65-69).",
        "R2 THE IMPERSONATION SESSION. packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts:325-333 mints createSession(targetUser.id, ..., { impersonatedBy: callerId, expiresAt: now + 3600 s }): the session's userId is the TARGET and impersonatedBy is the admin, with a lifetime of IMPERSONATION_SESSION_SECONDS = 3600 (:214). The admin's own token is parked in the signed admin_session cookie (:342-348), and the session cookie becomes the target's (:349). Before minting, the endpoint checks the caller's identity (the vendor's 401), then the CALLER's posture (a held caller is refused 403 with the gate's own code, :286-300), then platform-admin standing (:309-311), then that an admin target is protected (:319-322). It never asks the TARGET's posture. Can the gate tell the two sessions apart? The data is present: session.impersonatedBy rides the session that the customSession callback receives and returns unchanged (auth-manager.ts:4494, 4595-4597), and the console's banner reads it from that same payload (objectui packages/app-shell/src/layout/ImpersonationBanner.tsx:7-13, 58). The gate does not consult it today. There is a precedent for a door deciding on it: the print door refuses an impersonation session with PERMISSION_DENIED (packages/services/service-print/src/print-routes.ts:67-75, 273-276; contract packages/spec/src/contracts/auth-service.ts:435-441). That precedent is a narrowing; no door relaxes on it. No test on origin/main pins the held-TARGET case either way. The three test files that mention both impersonation and a posture pin the held CALLER (packages/verify/src/held-session-vendor-admin-doors.pin.test.ts:15-16, 289-292, 324-325, 356), the print door, or the principal mint.",
        "R3 THE EXITS. Server: /admin/stop-impersonating is reachable from the held session, by reading. It sits under the /auth/* namespace that the transport seams exempt (auth-gate.ts:363-379), and it is deliberately classified as NOT held: 'the EXIT from an impersonation, a remediation-shaped request like sign-out, carried by the impersonated session; holding it would trap that session rather than protect anything' (admin-impersonate-endpoint.ts:185-187, pinned by admin-impersonate-endpoint.held-caller.test.ts:45-47). Its behaviour on a booted server is NOT MEASURED, because this is a readings-only dispatch with no build closure. Console, on objectui 12dc7b47c3: ConsoleShell mounts the ImpersonationBanner in flow (packages/app-shell/src/console/ConsoleShell.tsx:193) and the RemediationOverlay after the routes (:214). The overlay is 'fixed inset-0 z-[200]' over a 95%-opaque blurred background (packages/app-shell/src/console/RemediationOverlay.tsx:29). The banner is 'sticky top-0 z-50' (ImpersonationBanner.tsx:112), and its 'Stop impersonating' button (:60-96, 129-139) is the working exit. So the overlay covers the banner and takes its clicks. The overlay's own actions are the change-password form (RemediationOverlay.tsx:59-118) and SignOutLink (:45-57, 116), and nothing in it reads session.impersonatedBy. That is why 'Sign out instead' is the only visible exit: it ends the impersonation session, and the admin signs in again. The exit exists on the server and on the page; the overlay hides it. The overlay also offers the impersonator the target's own remediation forms: change password, or for MFA_REQUIRED authenticator enrolment (:134-226). Either form acts on the target's credentials, which an impersonator should not be offered. The overlay is raised by ANY 403 whose nested error.code is one of the three posture codes, on any request (objectui packages/auth/src/auth-gate-events.ts:35-39, 62-71).",
        "R4 WHAT CHANGING THE GATE WOULD EXPOSE. The gate's stated purpose: 'Some auth policies (password expiry, enforced MFA) must block an authenticated user from PROTECTED RESOURCES until they remediate' (packages/core/src/security/auth-gate.ts:6-10). ADR-0069 D3: 'Gate at the session-validation seam so a half-authenticated (password-only) session cannot read data.' (docs/adr/0069-enterprise-authentication-hardening.md:87). The forced reset serves a routine reset, or the reset 'an administrator issues because they suspect the account is compromised' (packages/plugins/plugin-auth/src/forced-reset-revocation.ts:9-16). The threat it answers is an untrusted PASSWORD credential. An impersonation session does not come from the target's password: it is minted from the admin's own session, after the admin's own posture has been checked (R2). So exempting it lets no password holder through, including an attacker holding a compromised password. What it would let through is the admin acting AS the user before the user rotates: reading with the user's visibility, and writing in the user's name, because an impersonated write is attributed to the impersonated user (packages/rest/src/rest-server.ts:2983-2986). The login row still records the admin as actor (packages/plugins/plugin-auth/src/auth-session-audit.ts:157-173). Is the user's data otherwise reachable by the admin? Yes. Impersonation needs platform-admin standing, and admin_full_access carries the '*' modifyAllRecords super-user grant, which 'covers private/managed objects on the server, so the read and write bypass reach every entry' (packages/core/src/security/effective-object-permissions.ts:72-82). Impersonation adds the user's own view and the user's name on writes, exactly as it does for any unheld user. A relaxation would NOT change these: API keys (keyed by owner, never a session, and already not held by credential-scope codes); the forced reset's revocation (on by default), which still ends every session of the user, impersonation sessions included, because they are that user's rows (forced-reset-revocation.ts:20-24); and the print door's impersonation refusal. One variant is hazardous: also exempting MFA_REQUIRED, whose scope is account ('every credential the user holds is held', execution-context.zod.ts:46-48), would contradict the declared scope.",
        "R5 GOVERNING TEXT. ADR-0069 D1 :50 (expiry: 'force /change-password'), D3 :87, and the amended note :93-98: 'password expiry (D1) holds password sign-in alone, so the user's keys keep working; and an administrator's forced password reset (D7) carries an explicit revocation of the user's sessions and API keys, on by default and off only as the administrator's explicit act.' Spec, execution-context.zod.ts:61-63: 'Natural password expiry and the forced reset hold the password sign-in only. Enforced MFA holds the account.' Code premise, auth-manager.ts:8893-8894 (quoted in R1). No ADR governs impersonation's scope: git grep -i impersonat docs/adr returns one hit, ADR-0093:376, which is unrelated. Its governing text is the endpoint docblock. admin-impersonate-endpoint.ts:32-33 records the maintainer ruling of 2026-08-20 forbidding a raw mount. :78-83 protects admins as targets. :106-114 refuses a held CALLER, because 'minting a session as another user is one' protected resource. :185-187 leaves the exit unheld. The docs make impersonation Step 4 'Verify' of the admin onboarding flow (content/docs/permissions/administrator-guide.mdx:43-44, 131-134), right after Step 2, where Create User and Import force a password change on first login (:80-81). content/docs/permissions/authentication.mdx:973-977 adds: 'While mustChangePassword is set, the user is blocked with 403 PASSWORD_RESET_REQUIRED on every protected route'. Reach: create-user and set-user-password default mustChangePassword to true (packages/plugins/plugin-auth/src/admin-user-endpoints.ts:597, 701), and the import 'temporary' policy stamps it (admin-import-users.ts:46-51). So every admin-provisioned user is held until their own first sign-in."
      ],
      "tests": "None run: this is a readings-only dispatch (no worktree, no build closure, no tests). Every reading is a source read with git show or git grep on objectstack origin/main 08d201a80eeec307ba07ecd1236cc8dfb6d74b09 and on objectui 12dc7b47c340d9bd80b35406d890c2d27c8017dc, the .objectui-sha at that main. HotCRM was read at its local origin/main ref b94cf85, unfetched: 0 files mention impersonation. NOT MEASURED: the stop-impersonating behaviour of a held impersonation session on a booted server, and the overlay covering the banner in a browser (read from the class names, not rendered). Reason for both: readings only.",
      "gates": "None run: there is no diff and no worktree, so dispatch-gates --commands has no change set to derive from.",
      "mcp_calls": "0",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/24762/comments (this report, via scripts/pm/post-stamped.mjs). Reads, not writes: REST GET of issue 24762, its comments, and comment 6285123386 (R531).",
      "deviations": "git fetch origin main in the shared checkout /home/user/objectstack advanced the shared refs/remotes/origin/main (7282a950b to 08d201a80); no file was edited.",
      "open_questions": [
        {
          "question": "Which change ends the strand: the target's forced-reset posture binding the administrator's impersonation session, behind a full-screen overlay that hides the working 'Stop impersonating' exit?",
          "options": [
            "A — Refuse to impersonate a held target. Change: after the protected-target check, the impersonate door asks the TARGET's posture through the postureOf oracle it already holds (admin-impersonate-endpoint.ts:249, wired at auth-manager.ts:4043-4048) and refuses a held target with a coded 4xx naming the reason. Code: no existing code names 'the target is held'. The door's own refusals use the vendor's flat {code, message} with vendor codes, kept out of the ledger on purpose (:97-104). It must NOT answer a posture code in the nested envelope: objectui raises the remediation overlay for the CURRENT session on any such 403 (auth-gate-events.ts:62-71), so the admin would be told to change their own password. The nearest existing code is the standard PERMISSION_DENIED, which the print door answers an impersonation with. A precise new code is an @objectstack/plugin-auth ledger entry (packages/spec/src/api/error-code-ledger.zod.ts:559-619), so it touches packages/spec and needs a domain:spec card; whether the synonym detector would refuse it as a PERMISSION_DENIED synonym is NOT MEASURED. Blast radius: one door, where a request that answers 200 today would answer 4xx (a narrowing). It covers only a target that is held at entry: a posture that arrives mid-session (an expiry tick, or MFA grace ending inside the 1-hour session) still strands the admin, with no exit. It also turns the documented Step 4 'Verify' into a refusal for every freshly provisioned user. Files: plugin-auth admin-impersonate-endpoint.ts, its tests and a changeset (domain:services), plus a ledger entry if a new code is chosen (domain:spec). Effort: S, plus S for the spec card.",
            "B — Exempt the impersonation session from the target's posture (a relaxation of an authentication gate). Change: one producer edit. The customSession block (auth-manager.ts:4589-4596) drops postures when session.impersonatedBy is set. Every seam reads user.authGate from that payload, so every door follows and the overlay never appears. Two scopes. B1 drops only the credential-scope postures (PASSWORD_RESET_REQUIRED and PASSWORD_EXPIRED), read off the declared AUTH_GATE_SCOPES table with no new vocabulary; MFA_REQUIRED (account scope) still holds, so an MFA-held target still strands the admin. B2 drops all three, which contradicts the declared account scope. Blast radius: the admin can act as a held user (reading with that user's visibility, writing in that user's name) before the user rotates, and nothing else changes (R4). Owed with it: correct the docblock premise at auth-manager.ts:8893-8894; amend ADR-0069 to record that an impersonation session is not the target's password sign-in (docs/adr, Tier H, the maintainer's approval); update authentication.mdx:973-977; add a plugin-auth changeset (an admission widening); add pins that a held target's impersonation session reads 200, that the target's own session still answers 403, and that an MFA-held target still answers 403; and run the ablation lanes/services.md:17 requires. Files: plugin-auth auth-manager.ts and tests (domain:services), plus ADR-0069 and the docs. Effort: S for the code, M with the governance. It is on the human floor (lanes/services.md:12-13).",
            "C — Keep the gate, and give the stranded screen a 'Stop impersonating' exit (objectui). Change: when session.impersonatedBy is set, RemediationOverlay offers Stop impersonating, using the banner's stop logic extracted into a shared hook. It stops offering the target's remediation forms to the impersonator, and says the user must change the password themselves. The server is unchanged, because the exit is already unheld (R3). Blast radius: UI only. It covers all three postures and a posture that arrives mid-session, and moves no boundary. The 403 toasts rendered under the overlay are cosmetic and remain unless views stop firing. Files: objectui packages/app-shell/src/console/RemediationOverlay.tsx, packages/app-shell/src/layout/ImpersonationBanner.tsx (the extraction), the packages/i18n locales, tests, and an @object-ui/app-shell changeset (the domain:ui seat in objectui). A separate objectstack console pin-bump card follows (lanes/ui.md:20). Effort: S, plus the pin bump. It does not serve Step 4 'Verify' for a held user: the admin sees the overlay and leaves.",
            "D — A + C: a clear refusal at the door, plus an exit for postures that arrive mid-session. It carries the costs of both, and it still blocks Step 4 for every provisioned user.",
            "E — B1 + C: credential-scope postures (forced reset and expiry) do not bind an impersonation session, and the console exit covers MFA_REQUIRED and any posture that arrives mid-session. Its costs are B1's (including the ADR-0069 amendment) plus C's."
          ],
          "axes": {
            "business_need": "Measured. The documented admin flow is Step 2, add people, then Step 4, verify by impersonating (administrator-guide.mdx:43-44, 80-81, 131-134). Create User, Set Password and Import with temporary passwords all hold the user by default (R5). So impersonating a just-provisioned user is the main path, not an edge: the card's reach is 'support admins impersonating new or reset users', measured on a HotCRM install. Only B and E serve it. A and C turn it into a clean dead end, and the Explain-access tool remains (administrator-guide.mdx:135-140). Impersonation is a platform action (packages/platform-objects/src/identity/sys-user.object.ts:343-358), not app metadata: HotCRM's tree mentions it 0 times.",
            "long_term_soundness": "C is right under any choice: the exit exists and is deliberately unheld, so hiding it is a UI defect, and postures can arrive mid-session. B1 makes the code agree with the declared scope model (credential means password sign-in, execution-context.zod.ts:49-50) instead of the docblock premise that every session is the password sign-in, with one edit at the producer. It is not a consumer tolerance: the seams stay untouched and read one carrier. A adds a second place that asks the gate's question, needs a code the ledger lacks, and still needs C for mid-session postures.",
            "ai_error_resistance": "No option adds an authorable metadata key. A is the loud-refusal shape, but it carries a trap: answering a posture code there would raise the admin's own remediation overlay. B1 derives its exemption from the declared AUTH_GATE_SCOPES table, so no second list can drift; B2 would contradict a declaration. Today a 403 PASSWORD_RESET_REQUIRED on an impersonation session names the TARGET's posture, while every client reads it as its own. B1 removes that ambiguity for the credential postures, and C makes the client handle it.",
            "startup_scope": "C is the smallest: UI only, with no server change, no ledger entry, no ADR and no new gate. B1 is small code, but it carries an ADR amendment and relaxes an authentication boundary six days after ruling C' (2026-10-14). A spends a code, and possibly a spec card, on a refusal that blocks the main use. No option adds a gate."
          },
          "recommendation": "E (C + B1). C is the strand fix itself and moves no boundary. B1 serves the documented, high-frequency verify-a-new-user path, and it brings the gate in line with the declared credential scope: an impersonation session is minted from the admin's own unheld session, not from the target's password, so the threat the forced reset answers is absent from it, and the admin already reaches the user's records through the super-user grant. If the maintainer declines to relax an authentication gate now, choose C alone. Do not choose A: it adds a code and a second check to refuse the main use case, and it still needs C."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    [Decision] 模拟登录一个「须改密码」的用户时,该用户的强制改密是否也拦住管理员的模拟会话?

    domain:services seat 2 (seat post #21118) · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T09:28Z。依据:本卡只读读数报告(os-dev-report,origin/main 08d201a80e,objectui 控制台 pin 12dc7b47c3)。

    Release: session_01UmqzD97e14iws6hmVhgZ5y · 因:只读读数已交付,余下是放宽认证闸门的人工地板决定 · 去向:needs-user-decision,本笔清 assignee。

    一句话问题: 管理员按文档「第 4 步 验证」去模拟一个刚创建的用户(新建用户默认「下次登录须改密」),结果整页被「你必须修改密码」挡住,改的还是被模拟用户的密码,唯一出路是退出登录。

    背景(实测)

    Governing text

    • ADR-0069 D3:「Gate at the session-validation seam so a half-authenticated (password-only) session cannot read data.」及修订注记:到期与管理员强制重置只拦密码登录(credential 范围),MFA 拦整个账号(account 范围)。
    • packages/spec/src/kernel/execution-context.zod.ts:「Natural password expiry and the forced reset hold the password sign-in only. Enforced MFA holds the account.」
    • auth-manager.ts 闸门说明的前提句:「Every scope holds a session, because a session IS the password sign-in」——模拟会话恰好不是被模拟者的密码登录,前提在这里不成立。
    • 模拟登录本身无 ADR,治理文本是 admin-impersonate-endpoint.ts 的说明(拒绝被拦住的调用者;出口不拦)。

    是否改协议: 方案 B1 改的是运行时认证闸门的适用范围(不改 spec 键、不改错误码),但要修订 ADR-0069 的一句记录(Tier H,须你批准)。

    前提(复核命令)

    • 闸门不读 impersonatedBy:git grep -n "impersonatedBy" origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts(对照词 computeAuthGate 必中)。
    • 出口不受闸门约束:git grep -n "holding it would trap" origin/main -- packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts。

    选项

    选项 做什么 客户可感知后果 工作量
    A 拒绝模拟被拦住的用户 模拟入口先查目标是否「须改密」,是则 4xx 「验证新用户」这一步对每个新用户都变成明确的拒绝;会话中途才出现的拦截仍会困住管理员 services 1 PR(S);若要精确错误码另需 spec 卡;无先例
    B1 密码类拦截不约束模拟会话 customSession 在 impersonatedBy 存在时去掉 credential 范围的两个姿态(须改密、密码过期),MFA 照拦 管理员能以该用户视角验证;用户自己登录仍须先改密 services 1 PR(S,含钉子与消融)+ ADR-0069 修订 PR(Tier H)+ 文档;无先例
    C 只修 UI 出口 遮罩在模拟会话里给出「停止模拟」,不再向模拟者提供目标的改密表单 不再被困,但「验证新用户」仍做不成,只能先退出 已立卡 objectui#13208
    E = B1 + C 两者都做 能验证新用户;MFA 或中途出现的拦截也有出口 B1 + C

    业务含义直译: A = 「新员工没改密码前,前台主管不能替他看一眼系统」;B1 = 「主管用自己的工牌进,员工自己的门禁卡仍须先换密码」;C = 「门卡住时至少有个能出去的门」。

    推荐:E(B1 + C;C 已立卡,本卡只需裁 B1)

    • 终态(按①): 两年后,模拟登录是建立在管理员自己已验证会话上的客服工具:被模拟者的凭据健康(改密、过期)不约束它,账号级约束(MFA)和审计照旧。这与主流平台的建模一致:模拟会话来自管理员自身凭据,而不是被模拟者的密码(参照 Salesforce「Login As」)。
    • 为何不选 A: 多一处判断、可能要新错误码,却把文档里的主用例变成拒绝,而且仍要靠 C 处理中途出现的拦截。
    • 为何不只选 C: 不移动任何边界,但「验证新用户」这件实际要做的事仍做不成。如果你现在不想放宽认证闸门,C 单独成立。
    • 置信缺口: 已启动服务器上的「停止模拟」行为与遮罩盖住横幅,都是按源码读的,未在浏览器实测。
    • 自检:只看①选 E;②③④ 是否翻转:否(④ 只影响先做 C、后做 B1 的时序)。

    os-decision-facets

    • ① 项目长远合理性:B1 让代码与已声明的范围模型一致(credential = 密码登录),在生产者一处改,不在各个消费端放宽;A 多一处同问的检查。
    • ② 实际业务拉动:文档的开通流程「第 2 步建人 → 第 4 步模拟验证」,新建/设密/导入都默认须改密,HotCRM 维护者走查实测撞上。
    • ③ 防 AI 犯错:B1 从已声明的 AUTH_GATE_SCOPES 表推导豁免,不另立清单;今天模拟会话上的 403 改密码报的是被模拟者的状态,每个客户端却当成自己的,B1 消除这个歧义。
    • ④ 创业阶段不扩散:C 最小(只改 UI);B1 代码小但带一次 ADR 修订;三者都不新增门禁。
      Prior rulings read: impersonat,mustchangepassword,password_reset_required,authgate → 0 hits; none; thread: none; repo: objectstack-ai/objectstack

    裁后执行

    • 裁 E: 席位派发本卡做 B1:plugin-auth customSession 按 AUTH_GATE_SCOPES 在模拟会话上去掉 credential 姿态;钉子三条(模拟会话读 200、目标自己的会话仍 403、MFA 被拦的目标仍 403)并消融;另起 ADR-0069 修订 PR 留 draft 等你批;同步 authentication.mdx。C 由 objectui 车道照常做。
    • 裁 C: 本卡以「由 objectui#13208 承接」关闭(not planned),不改服务端。
    • 裁 A: 本卡派发入口拒绝 + C 照做;若要精确错误码,另立 spec 卡。

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Director delta, batch #376 item 1 — director seat, summon #46, session_013414Vtz3Z8PrXFetUziHoh, 2026-10-20T10:12Z. Read on origin/main 5cb6e6e0aa, fetched in this act. Thread read through 6287853745. Only what this seat re-checked or changes is below; what it agrees with is not restated.

    Premises, re-run

    • git grep -n "impersonatedBy" origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts → 0 hits. Control word computeAuthGate in the same file → 8 hits. The gate still never reads the impersonator. customSession still stamps computeAuthGate(user.id, activeOrganizationId, …), which returns gates[0].
    • git grep -n "holding it would trap" origin/main -- packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts → 1 hit (:187). The exit is still unheld.

    Governing text, re-run

    • git grep -n -iE 'impersonat|must_?change_?password|PASSWORD_RESET_REQUIRED' origin/main -- AGENTS.md docs/adr packages/spec/src → AGENTS.md 0. docs/adr: ADR-0069:195 (the status block: PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold "the credential (password sign-in alone)"), ADR-0092 (the stamp only) and ADR-0093:376 (unrelated). packages/spec/src: execution-context.zod.ts (the scope table and the authGate describe), contracts/auth-service.ts:436-441 (impersonatedBy declared, read by the print door) and auth-config.zod.ts.
    • Correction to the facet block's Prior rulings read: line. check-prior-rulings.mjs reports docs/adr "0 hit of 780 searched" for these terms, but git grep finds ADR-0069:195. That is an instrument miss in the tool's term matching. The card's own Governing text section already cites ADR-0069 correctly, so the analysis does not change.
    • The standing ruling on this vocabulary is auth-gate parity: a request carrying an API key beside a session cookie held by ADR-0069 is refused at REST but admitted by the five raw-host route families — one cross-seam answer is owed (session versus user) #23287 ruling C′ (6159450584). Its item 1 declares the scopes: account holds every credential of the user, and credential holds password sign-in alone. It says nothing about an impersonation session. The docblock premise at auth-manager.ts "Every scope holds a session, because a session IS the password sign-in" is a derivation in the code that is true of the password session only. It is not the ruling's text.

    Director increments

    1. B1 brings the code in line with the declaration; it is not a loosening against one. AUTH_GATE_SCOPES (spec) and ADR-0069's amended note both scope the credential postures to the password sign-in. An impersonation session is minted from the administrator's own session, which has already passed its own posture check. It is not the target's password sign-in. Under the standing rule (spec declaration > implementation), the code holds more than the declaration says. It is still a loosening of an authentication gate as users see it, so it stays on the human floor and goes to the batch instead of a class-1 self-ruling.
    2. Workload correction: no ADR amendment. No sentence of ADR-0069 (D3, its amended note, the status block) says an impersonation session is held, so there is nothing to supersede or amend. B1 is one domain:services PR (S): the producer edit, the docblock premise corrected, content/docs/permissions/authentication.mdx updated, a changeset, the pins and the ablation. No Tier H PR, and no spec card. The card's "M with the governance" becomes S.
    3. Execution parameter: filter, do not drop the first posture. computeAuthGate returns only the first posture. A target held by both PASSWORD_RESET_REQUIRED and MFA_REQUIRED must still answer MFA_REQUIRED on the impersonation session. So when session.impersonatedBy is set, keep only the postures whose AUTH_GATE_SCOPES[code] is account, then take the first. That compound case is a fourth pin, beside the card's three. Ablate the filter (the held-target case turns red) and restore.
    4. C is independent of this ruling. console: the password-change overlay covers the impersonation banner, so an admin impersonating a held user can only sign out — "Stop impersonating" is hidden behind it objectui#13208 was filed at 09:27 UTC. It has no labels yet, so triage's first touch is still owed. It proceeds under any letter.

    Facets, compared

    • ① 同:B1 在生产者一处按已声明的范围表收窄持有面,特例反而减少(代码不再比声明多拦一类会话)。
    • ② 同:拉动是实测的(HotCRM 维护者走查 + 文档开通流程第 4 步;新建、设密、导入都默认须改密)。
    • ③ 同:豁免从 AUTH_GATE_SCOPES 推导,不另立清单;复合姿态按第 3 条过滤,MFA 不会被连带放掉。
    • ④ 改:B1 不再带 ADR 修订,E 比 C 多出的只是一个 S 级 services PR;仍不新增门禁。

    Recommendation: E (B1 + C) — unchanged. 只看①选 E;②③④ 是否翻转:否(④ 的工作量下修只让 E 更便宜)。Workload of E: B1 = domain:services · 1 PR (S) · no precedent; C = objectui#13208 (S) plus the objectstack console pin bump.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #376 item 1 · letter E · maintainer 「同意」 2026-10-20T10:23Z

    Director seat, summon #46, session_013414Vtz3Z8PrXFetUziHoh. Presented in this session's chat as batch #376 (one card), from the card's decision 6287853745 and the director delta 6288600882. The maintainer's reply 「同意」 accepts the recommendation, letter E (B1 + C). Thread read through 6288600882 at this write; nothing newer.

    Governing text: ADR-0069 D3 and its amended note (ruling C′ on #23287, 6159450584); the ADR-0069 status block (PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold "the credential (password sign-in alone)"); AUTH_GATE_SCOPES in packages/spec/src/kernel/execution-context.zod.ts.

    What the ruling binds

    1. The rule. A credential-scope posture (PASSWORD_RESET_REQUIRED, PASSWORD_EXPIRED) does not hold an impersonation session. An account-scope posture (MFA_REQUIRED, and any future account-standing posture) still does. Why: the impersonation session is minted from the administrator's own session, which has passed its own posture check. It is not the target's password sign-in, and the password sign-in is the only thing a credential posture holds, per the declaration.
    2. Where: once, at the producer. In plugin-auth's customSession, when session.impersonatedBy is set, filter the postures resolveUserAuthGates reports to those whose AUTH_GATE_SCOPES[code] is account, then take the first that remains. ⛔ Do not drop gates[0]: a target held by both PASSWORD_RESET_REQUIRED and MFA_REQUIRED answers MFA_REQUIRED. No seam, door or client restates the rule. Reading, origin/main 5cb6e6e0aa, this act: customSession stamps computeAuthGate(user.id, activeOrganizationId, …), and computeAuthGate returns gates[0] (auth-manager.ts). How the impersonation fact reaches computeAuthGate (a parameter, or a filter at the call) is the dev's to choose: unverified.
    3. Pins (four), each ablated. (a) A PASSWORD_RESET_REQUIRED target's impersonation session reads 200 on a protected route. (b) The target's own session still answers 403 PASSWORD_RESET_REQUIRED. (c) An MFA_REQUIRED target's impersonation session still answers 403 MFA_REQUIRED. (d) A target held by both answers MFA_REQUIRED. Ablate the filter: (a) and (d) turn red. Then restore. This is the services lane's negative-pin commitment.
    4. Text owed in the same PR. Correct the computeAuthGate docblock premise ("Every scope holds a session, because a session IS the password sign-in") so it names the impersonation session. In content/docs/permissions/authentication.mdx, the passage "While mustChangePassword is set, the user is blocked with 403 PASSWORD_RESET_REQUIRED on every protected route" gains the impersonation sentence. Add a plugin-auth changeset.
    5. Not owed: no ADR amendment, no spec edit, no new error code, no new gate. No ADR sentence says an impersonation session is held (delta 6288600882, item 2), and the declaration's words already cover the case.
    6. C stands on its own card: console: the password-change overlay covers the impersonation banner, so an admin impersonating a held user can only sign out — "Stop impersonating" is hidden behind it objectui#13208. On an impersonation session the overlay offers "Stop impersonating", and it no longer offers the target's remediation forms to the impersonator. Triage's first touch is owed. The objectstack console pin bump follows, per lanes/ui.md.
    7. Unchanged: the forced reset's revocation (on by default) still ends every session of the user, impersonation sessions included. The print door's impersonation refusal and API-key admission are unchanged too.

    Workload of the letter (the card's column, as corrected by delta 6288600882): B1 = domain:services · 1 PR (S) · no precedent; C = objectui#13208 (S) plus the console pin bump.

    Execution. This card: needs-user-decision → pm:queue, keeping domain:services, priority:p2 and area:identity. The services lane dispatches B1 per items 1–5. The authentication-gate decision this card raised is answered by this ruling, so the lane does not file a second decision card for it. A falsified premise goes back to the box.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 10 · 2026-10-20T10:52Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24762-impersonation-credential-gate
    Worktree: objectstack-issue-24762
    Domain: domain:services
    Seat: domain:services#1
    File surface: B1 of ruling 6288774214 only: packages/plugins/plugin-auth/src/auth-manager.ts (customSession's gate stamp ~:4584–4600 and computeAuthGate :8896 with its docblock, at b3fc122f07), four pins beside it in plugin-auth, the mustChangePassword passage of content/docs/permissions/authentication.mdx (:981), and one new .changeset/24762-*.md. ⛔ Not auth-manager.ts ~:9284–9467 (PR #24843) or ~:105/:2971 (PR #24846), ⛔ not auth-plugin.ts (PR #24853), ⛔ not packages/spec/**, ⛔ no ADR, no new error code, no new gate, ⛔ no objectui edit (C is objectstack-ai/objectui#13208), ⛔ the forced reset's revocation, the print door and API-key admission unchanged (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"
    Clause-②: no
    Responsibility: a credential-scope posture (PASSWORD_RESET_REQUIRED, PASSWORD_EXPIRED) holds an impersonation session, so an admin impersonating a user who must change their password is stranded on the target's change-password screen | customSession stamps computeAuthGate's gates[0] with no regard to session.impersonatedBy; AUTH_GATE_SCOPES declares each posture's scope | support admins impersonating new or reset users (HotCRM pass, F-12)
    Ruling-ref: 6288774214 (letter E, maintainer 「同意」; B1 is this lane's, retrieved at this claim)
    Thread-read: 6288774214
    Serial constraints cleared: The three open PRs on plugin-auth edit other regions (PR #24843 auth-manager.ts ~:9284–9467 and authentication.mdx ~:564; PR #24846 auth-manager.ts ~:105 and ~:2971, in the merge queue; PR #24853 auth-plugin.ts), read from their hunk headers at this claim; whichever lands second merges main. Neither the 18.x epic's declared territory (#15194) nor any other claim names customSession or computeAuthGate.

    Readings taken on origin/main b3fc122f07.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24762,
      "status": "done",
      "branch": "claude/issue-24762-impersonation-credential-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24866",
      "session": "session_011TdZ8GjixNZFLz1xJD9zzK",
      "premise_still_valid": true,
      "summary": "B1 of ruling 6288774214 (items 1-5) is implemented at the producer. customSession reads session.impersonatedBy and passes { impersonation: true } to computeAuthGate. On an impersonation session, computeAuthGate answers accountScopeAuthGate(gates): the first account-scope posture (AUTH_GATE_SCOPES) wherever it sits, which is @objectstack/core's existing reader, the one the API-key seam uses. Without the option it answers gates[0], byte for byte as before, so the other two callers (the impersonate door's caller check, :4047 at the claim; assertActorNotHeld, :7959) are unchanged for every session. The computeAuthGate docblock premise now names the impersonation session (and the resolveUserAuthGates docblock with it). authentication.mdx's mustChangePassword passage gains the impersonation sentence. A plugin-auth patch changeset carries Clause-②: no. Premises measured: H1 holds (customSession stamped computeAuthGate, which returned gates[0]). H2 holds (AUTH_GATE_SCOPES is in packages/spec/src/kernel/execution-context.zod.ts, read only), but the table is not imported into plugin-auth: @objectstack/core already exports accountScopeAuthGate over it, so the filter is reused, not restated. H3 holds on a real boot: GET /auth/get-session with the impersonation bearer answers session.impersonatedBy = the admin's id, and the filter fires in customSession (the ablation proves it is the filter's input). Reproduction: the ablation leg is main's code path (the filter removed, gates[0] for every session). On it, (a) answers 403 instead of 200, the defect as reported. Not touched: packages/spec, the three open PRs' regions (#24843, #24846 and #24853 are on main now and merged in as they are), the revocation, the print door, API-key admission. The worktree is removed after this comment is read back; the branch head c62a583b73 is on the remote.",
      "files_changed": [
        "packages/plugins/plugin-auth/src/auth-manager.ts",
        "packages/plugins/plugin-auth/src/auth-manager.test.ts",
        "packages/verify/src/held-session-impersonation-target.pin.test.ts",
        "content/docs/permissions/authentication.mdx",
        ".changeset/24762-impersonation-credential-posture.md"
      ],
      "tests": "Head c62a583b73 (origin/main 950f949a9c merged in), every run under scripts/pm/os-verify-lock.sh, each exit captured before any pipe. (1) pnpm exec turbo run build --filter='@objectstack/verify^...' --concurrency=2: exit 0, 'Tasks: 48 successful, 48 total'. (2) pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: exit 0, 'Test Files 170 passed (170) / Tests 3264 passed | 10 skipped (3274)'. (3) pnpm --filter @objectstack/plugin-auth typecheck: exit 0 ('check:test-typecheck: OK ... 10 file(s) / 88 error(s) / 23 pinned signature(s) held'). (4) pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2 src/held-session-impersonation-target.pin.test.ts src/held-session-vendor-admin-doors.pin.test.ts src/held-session-platform-admin.pin.test.ts: exit 0, 'Test Files 3 passed (3) / Tests 49 passed (49)'. (5) pnpm --filter @objectstack/verify typecheck: exit 0. Pins: verify held-session-impersonation-target.pin.test.ts (8 tests: (a) the impersonation session reads GET /data/sys_user?limit=1 200 with no authGate on its payload; (b) the target's own session 403 PASSWORD_RESET_REQUIRED; (c) MFA target's impersonation session 403 MFA_REQUIRED; (d) both, 403 MFA_REQUIRED; controls: unheld 200/200, MFA and compound holds armed on the own session, the forced reset's revocation (default on) ends the impersonation session: revoked.sessions at least 1, then 401). Each refusal asserts status 403 + error.code (REST's envelope has no success field, measured). The reset is armed through POST /auth/admin/set-user-password with its mustChangePassword default. plugin-auth auth-manager.test.ts: 4 producer cases on customSession's stamped user.authGate (reset, expiry, MFA, both). ABLATION (one leg, run on ac444bcfc7, before the last main merge, which touched neither file): scripts/ablation-replace.mjs replaced the filter line with a planted no-op (a void expression over the string ablation-24762-filter-removed, accountScopeAuthGate and options): anchor x1 to x0, blob 45d22147db17 to 3c94be9c4c2f. pnpm --filter @objectstack/plugin-auth build exit 0. ablation-dist-preflight: the plant 'hit packages/plugins/plugin-auth/dist/index.js' and 'dist/index.mjs', and the filter text 'options.impersonation === true' --absent exit 0. Mutated: verify pin 'Tests 3 failed | 5 passed (8)'. (a) 'expected 403 to be 200'. (d) 'expected PASSWORD_RESET_REQUIRED to be MFA_REQUIRED'. The revocation control red at its first read, 'expected 403 to be 200'. (b), (c) and the three controls stayed green. plugin-auth pins 'Tests 3 failed | 1 passed' (reset, expiry, both red; MFA green). Restore: 'blob after restore 45d22147db17 == blob at HEAD 45d22147db17', 'git diff HEAD empty', git status --porcelain empty. Rebuilt: plant --absent exit 0, filter text present exit 0. Re-run: verify 'Tests 8 passed (8)', plugin-auth 'Tests 4 passed'. The direction matches the ruling: (a) and (d) red, (b) and (c) green. The revocation control also went red, because it reads the impersonation session before revoking. A first leg deleted the line outright. Its JS landed and the readings were the same, but its DTS build exited 1 (TS6133 on the then-unused import and parameter), so it was replaced and is not relied on.",
      "gates": "Local subset on c62a583b73: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 100 commands for 5 changed paths ('merge base 950f949a9'). All 100 were run under the lock, each exit captured before any pipe into ran.list. 98 answered exit 0. Two answered exit 3, PREREQUISITE NOT MET: 'pnpm --filter @objectstack/spec run check:skill-examples' (no client SDK build) and 'pnpm check:dual-build-cjs-loads' (21 packages outside the closure have no dist). After turbo build of @objectstack/client and @objectstack/client-react (exit 0), check:skill-examples answered exit 0 ('262 prose examples type-check across 3 surface(s)'). --ran on the updated record: 'Run reconciliation - 100 derived, 99 run, 1 NOT-MEASURED, 0 UNRUN', '--ran exit 0'. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET, a whole-repo build outside this diff's closure. Direct probe: require of plugin-auth dist/index.js loads (exit 0, 155 exports). CI runs the family. Not run locally: node scripts/check-platform-checklist.mjs (no export renamed or removed); test:repo (not named by the derivation, left to Test Core); check:dev-prereqs and check:skill-frame-freshness (no packages/spec source or four-axis frame file touched). PR check-runs on c62a583b73, read once at report time, not re-run and not awaited: 18.x upgrade harness (opt-in): completed/skipped; Check Changeset: in_progress; Check Documentation Links: in_progress; Check PR Size: in_progress; Flag docs affected by code changes: in_progress; Governed Surface Queue Guard: in_progress; Lint · CI, release & engine gates: in_progress; Lint · ESLint: in_progress; Lint · PM dispatch-gates: in_progress; Lint · docs & code guards: in_progress; Lint · repo & tooling gates: in_progress; Lint · scoped · query-options erasure: in_progress; Lint · scoped · slot lookup & corpus: in_progress; Lint · scoped · tooling self-tests: in_progress; No other open PR may claim the same issue: completed/success; No other open PR may claim the same single-writer path: in_progress; Packed-tarball smoke (opt-in): completed/skipped; Part-of PR must not also close its card: in_progress; Spec property liveness: in_progress; The card this PR closes must claim this branch: in_progress; Type Check · consumer gates: in_progress; Type Check · debt ledger: in_progress; Type Check · source gates: in_progress; Type Check · workspace: in_progress; filter: in_progress.",
      "mcp_calls": "0",
      "api_writes": "2 - (1) POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay, request fw-20261020T115935Z-aacf0a, run 39418918557 success): pr_create, executed as objectstack-fleet[bot] as POST /repos/objectstack-ai/objectstack/pulls (draft, #24866) + POST /repos/objectstack-ai/objectstack/issues/24866/assignees (os-justin). Read-back: 10932 bytes sent, 10932 stored, identical; assignees os-justin. (2) This os-dev-report comment: scripts/pm/post-stamped.mjs to POST /repos/objectstack-ai/objectstack/issues/24762/comments. No labels written. Not REST: git push (empty-branch probe, then 6 pushes). Reads only: REST GET of issue 24762, its comments, pull 24866 and its head's check-runs.",
      "deviations": [
        "Pin location. The claim's file surface put the four pins 'beside it in plugin-auth'. No plugin-auth harness serves a protected route through a transport seam, and the ADR-0112 envelope is the seam's. So the route-level pins are a new file outside the declared surface, packages/verify/src/held-session-impersonation-target.pin.test.ts, beside the existing real-boot held-session pins. The producer-level cases are in plugin-auth's auth-manager.test.ts, on its existing customSession double: findOne overridden, and getSessionCallback gains an optional config argument.",
        "H2's suggested route (import AUTH_GATE_SCOPES into plugin-auth) was not taken. @objectstack/core already exports accountScopeAuthGate over that table, and reusing it keeps one reader.",
        "Lock hold. Per this dispatch, the 100-command gate battery ran under the shared verify lock. Chained after the test unit, it held the lock 22m06s, and the lock tool flagged holder-side starvation.",
        "Shared refs. Three git fetch origin main calls (one in the shared checkout at start, two in this worktree) advanced the shared refs/remotes/origin/main, finally to 81120caac8. No file outside this worktree was edited.",
        "Stray file. A subshell variable-scope slip wrote the first build's PID file to /build1.pid at the filesystem root. It was moved into this issue's scratchpad directory, and no other stray file remains.",
        "Merges. origin/main was merged twice (abd5acdaf7, then 950f949a9c, which carries #24846 and #24853 on plugin-auth). There were no conflicts, and the full local verification above re-ran on the merged head. origin/main has since moved to 81120caac8 (plugin-audit, plugin-approvals, docs/qa): no file of this diff's packages, so the PR was opened without a third merge.",
        "Commit trailers. The pair is the model-free one AGENTS.md prescribes, not the harness reminder's model-named Co-Authored-By. The PR footer is AGENTS.md's session-URL form."
      ],
      "open_questions": [
        {
          "question": "How the impersonation fact reaches computeAuthGate (the ruling left it to the dev; recorded with its reading, already taken).",
          "options": [
            "A - a parameter on computeAuthGate (options.impersonation, default absent); the session gate keeps one carrier. Taken.",
            "B - a filter at the call: customSession calls resolveUserAuthGates and filters inline; the session posture is then derived in two places."
          ],
          "recommendation": "A. Business need: the same for both (both serve the measured verify-a-new-user path). Long-term soundness: A keeps one derivation, the carrier the doors cite. AI error resistance: the option is opt-in and named, an omitted option answers the old value, and no second copy of the filter can drift. Startup scope: the same size, no gate added."
        },
        {
          "question": "The asking doors (assertActorNotHeld at the vendor-served platform-admin doors, and the impersonation door's caller check) still ask computeAuthGate with the option omitted. So an impersonation session of a credential-held target that reaches one of them is refused 403 with the credential code, where the door's own platform-admin check would also refuse it 403. Should they learn the session's impersonation fact?",
          "options": [
            "A - leave them (this PR). No request is admitted that was refused. The hard stop kept their answers unchanged, and every such door refuses a non-admin impersonation target on its own check anyway.",
            "B - pass the session's impersonatedBy through resolveActor, so they answer as the session gate does. This widens a shared helper (resolveActor serves attribution too). In one edge it would ADMIT an impersonation session: a non-admin target who owns an SSO provider (registration requires platform admin, so only a demoted former admin) could then have its issuer rewritten or deleted through /sso/update-provider or /sso/delete-provider."
          ],
          "recommendation": "A. Business need: no measured pull, since no door's refusal changes and the console exit for any 403 posture is objectui#13208's. Long-term soundness: one rule at the producer, and the asking doors stay conservative. AI error resistance: B adds an admission path through a shared helper. Startup scope: A adds nothing."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed (PR Acceptance notes only; doc text in packages/spec, a hard stop here and not class a/b/c): packages/spec/src/contracts/auth-service.ts IAuthService.resolveUserAuthGates says 'the session gate carries its first entry on user.authGate'; on an impersonation session it now carries the first account-scope entry. Suggested wording is in the PR.",
        "carrier: none · noted, not filed (PR Acceptance notes only): @objectstack/core's accountScopeAuthGate docblock names only the API-key reading; plugin-auth now also uses it for an impersonation session."
      ]
    }

    Generated by Claude Code

  8. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24866 comment 6290854048 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T12:29Z


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T13:00Z

    reading result
    PR #24866 timeline added_to_merge_queue 2026-10-20T12:32Z → merged 2026-10-20T12:59Z
    origin/main by content squash c2d4072281 (one parent, b1676441ba). computeAuthGate answers accountScopeAuthGate(gates) on an impersonation session, and packages/verify/src/held-session-impersonation-target.pin.test.ts is present
    closing references the PR body closes #24762 only

    State change in this stroke: pm:dispatched off and the assignee cleared. The card closed completed on the merge.

    This lands B1 of ruling 6288774214 (letter E): a credential-scope posture no longer holds an administrator's impersonation session, and MFA_REQUIRED still does (ACCEPT 6290854048). C, the console overlay's "Stop impersonating", stays on objectstack-ai/objectui#13208, and the console pin bump follows it per lanes/ui.md.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:identityLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions