Skip to content

Commit 7035682

Browse files
fix(platform-objects): credential action params declare type password, so the dialog masks what is typed (#24867)
Fixes #24758 Clause-②: no (an input's presentation: the param still accepts the same string, and no accept set or served shape moves) ## What changed Every credential param on the identity objects' actions now declares `type: 'password'` instead of `type: 'text'`, so the console's action dialog draws a masked input and the typed value is no longer readable on screen or in screenshots. Labels, names, `required` and the posted body are unchanged. | Object | Action | Param | |---|---|---| | `sys_user` | `create_user` | `password` (the card) | | `sys_user` | `set_user_password` | `newPassword` (the card) | | `sys_user` | `change_my_password` | `currentPassword`, `newPassword` | | `sys_user` | `delete_my_account`, `enable_two_factor`, `disable_two_factor`, `generate_backup_codes` | `password` | | `sys_two_factor` | `enable_two_factor`, `disable_two_factor`, `regenerate_backup_codes` | `password` | | `sys_sso_provider` | `register_sso_provider` | `clientSecret` | Files: `packages/platform-objects/src/identity/sys-user.object.ts` (the claim's surface), `sys-two-factor.object.ts` and `sys-sso-provider.object.ts` (outside the claim's listed surface, see below), the pins in `packages/platform-objects/src/platform-objects.test.ts`, and a `patch` changeset for `@objectstack/platform-objects`. ## Read before the edit - **Console render (objectui at the pinned `.objectui-sha` `616df6598b42`).** `ActionParamDialog` builds every param through `paramToField()`, whose widget key is `resolveParamWidgetType(param.type)` → `resolveFormWidgetType`. `password` is a key of `fieldWidgetLoaderByKey` in `@object-ui/fields`, loading `widgets/PasswordField.tsx`, which renders its input with type `password` (with a show/hide toggle) and posts the string through `onChange` exactly as the text widget does. So a `password` param is masked in the console today; no objectui change is needed. - **Server.** No server path reads an action param's `type`: `resolvePassword` in `plugin-auth`'s `admin-user-endpoints.ts` reads `body.password` / `body.newPassword` as strings; `MASKED_ON_READ_FIELD_TYPES` / `isMaskedOnReadFieldType` and the generic-object plaintext warning in `object.zod.ts` judge object FIELDS only, never action params. `ActionParamSchema.type` is `FieldType`, which admits `password`. What the server accepts is unchanged. - **Every other password-like param** (`git grep -n "assword" -- 'packages/platform-objects/src/**/*.object.ts'`): the eight `sys_user` params and three `sys_two_factor` params in the table. A second sweep for credential-named params (`password`, `secret`, `token`) declared `type: 'text'` across `packages/**` and `examples/**` found one more, `sys_sso_provider.register_sso_provider#clientSecret`. ## Scope notes - `sys-two-factor.object.ts` is outside the claim's `File surface:`; the dispatch names "any other password-like param on the platform objects declared `type: 'text'`" as in scope, so its three params land here. - `sys_sso_provider`'s `clientSecret` is landed as a bounded in-place fix: the same defect class (a credential typed into an action dialog shown in clear), the same one-token mechanical change pinned by the same test, no open PR or claim on that file, and no new gate family. - No translation bundle moves: labels are unchanged, and `pnpm check:i18n` is green after the build. ## Pins - `credential action params declare 'password', so the dialog masks what is typed` in `platform-objects.test.ts`: a roster row per param above asserting `type === 'password'`, plus a sweep over every object exported from `./identity/index.js` that fails on any credential-named param (`/password|secret/i`, boolean toggles such as `mustChangePassword` excepted) not declared `password`; its positive control asserts the sweep reaches exactly the rostered params. - Pin sweep: the declarations and their strings ("leave empty to generate", "Current Password", the param names) were searched across every package, `qa/`, `docs/qa/platform-checklist/` and `content/docs/`. No existing pin asserts these params' type, so no pin moves. ## Verification record Base `abd5acdaf`, merged `origin/main` `9a06f0be0` before opening; head `d46ae39da`. - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0 (before and after the merge). - `pnpm --filter @objectstack/platform-objects test` (full suite): exit 0, 79 files / 1212 tests passed (after the merge). - `pnpm --filter @objectstack/core test:repo`: exit 0, 6 files / 82 tests. - `pnpm --filter @objectstack/plugin-auth test` (the endpoint consumer): exit 0, 170 files, 3260 passed / 10 skipped. - Build: `turbo run build` over the workspace after the merge, 76/76 tasks. - Ablation via `scripts/ablation-replace.mjs` (fix committed first; the test imports `src`, no dist): reverting `create_user#password` to `text` → 2 failed (roster row + sweep), restored to HEAD blob `7abf5fe3a3fb`; reverting `set_user_password#newPassword` → 2 failed (`SysUser.set_user_password#newPassword declares 'password'` + sweep), restored, `git diff HEAD` empty. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 commands, each run with its exit code captured before any pipe; all exit 0. `pnpm check:i18n`, `pnpm check:dual-build-cjs-loads` and `pnpm check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET, no build) and were re-run green after the build. `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: all 66 carry an exit code). After the merge, the changeset, ADR-0087, keyed-text-bounds, tenancy-census, nul-bytes and type-check coverage/debt families were re-run on `d46ae39da`: all exit 0. ## Acceptance notes - `@objectstack/setup` and `@objectstack/account` declare no `test` script; their filtered runs ran nothing. --- _Generated by [Claude Code](https://claude.ai/code/session_01G3YZBh5FVysqzzALckNTWy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ea5afe commit 7035682

5 files changed

Lines changed: 86 additions & 12 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): the password an administrator or user types into an action dialog is masked, no longer shown in clear text
6+
7+
Clause-②: no (an input's presentation: the param still accepts the same string, and no accept set or served shape moves)
8+
9+
Twelve credential params on the identity objects were declared `type: 'text'`, so the console's action dialog drew each as a plain text input and the typed password was readable on screen and in screenshots. Each now declares `type: 'password'`, which the dialog draws as a masked input:
10+
11+
- `sys_user`: `create_user`'s `password` and `set_user_password`'s `newPassword` (Setup › Users), `change_my_password`'s `currentPassword` and `newPassword`, and the `password` of `delete_my_account`, `enable_two_factor`, `disable_two_factor` and `generate_backup_codes`.
12+
- `sys_two_factor`: the `password` of `enable_two_factor`, `disable_two_factor` and `regenerate_backup_codes`.
13+
- `sys_sso_provider`: `register_sso_provider`'s `clientSecret`.
14+
15+
Labels, names, `required` and the posted body are unchanged: the value is still posted as the string typed, and no endpoint changes what it accepts.

‎packages/platform-objects/src/identity/sys-sso-provider.object.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const SysSsoProvider = ObjectSchema.create({
110110
{ name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer, e.g. https://acme.okta.com. Discovery is fetched from here unless an explicit URL is given below.' },
111111
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP, e.g. acme.com.' },
112112
{ name: 'clientId', label: 'Client ID', type: 'text', required: true, helpText: 'OAuth client ID issued by the IdP for this system.' },
113-
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' },
113+
{ name: 'clientSecret', label: 'Client Secret', type: 'password', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' },
114114
{ name: 'discoveryEndpoint', label: 'Discovery URL', type: 'text', required: false, helpText: 'Optional. OIDC discovery document URL. Leave blank to derive `<issuer>/.well-known/openid-configuration`.' },
115115
{ name: 'scopes', label: 'Scopes', type: 'text', required: false, placeholder: 'openid email profile', helpText: 'Optional. Space- or comma-separated OAuth scopes. Defaults to "openid email profile".' },
116116
// No "Map: User ID claim" param here on purpose. `@better-auth/sso@1.7.0-rc.2`

‎packages/platform-objects/src/identity/sys-two-factor.object.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const SysTwoFactor = ObjectSchema.create({
8282
requiresFeature: 'twoFactor',
8383
refreshAfter: true,
8484
params: [
85-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
85+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
8686
],
8787
resultDialog: {
8888
title: 'Two-factor authentication enabled',
@@ -110,7 +110,7 @@ export const SysTwoFactor = ObjectSchema.create({
110110
successMessage: '2FA disabled',
111111
refreshAfter: true,
112112
params: [
113-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
113+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
114114
],
115115
},
116116
{
@@ -126,7 +126,7 @@ export const SysTwoFactor = ObjectSchema.create({
126126
description: 'Regenerate backup codes? All previous backup codes will stop working immediately.',
127127
refreshAfter: true,
128128
params: [
129-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
129+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
130130
],
131131
resultDialog: {
132132
title: 'New backup codes generated',

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export const SysUser = ObjectSchema.create({
248248
requiresFeature: 'phoneNumber',
249249
},
250250
{ field: 'name', required: false },
251-
{ name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false },
251+
{ name: 'password', label: 'Password (leave empty to generate)', type: 'password', required: false },
252252
{
253253
name: 'mustChangePassword',
254254
label: 'Require Password Change On First Login',
@@ -296,7 +296,7 @@ export const SysUser = ObjectSchema.create({
296296
// `generatePassword` rides as a constant so an empty field generates.
297297
bodyExtra: { generatePassword: true },
298298
params: [
299-
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false },
299+
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'password', required: false },
300300
{
301301
name: 'mustChangePassword',
302302
label: 'Require Password Change On Next Login',
@@ -505,8 +505,8 @@ export const SysUser = ObjectSchema.create({
505505
successMessage: 'Password changed',
506506
refreshAfter: false,
507507
params: [
508-
{ name: 'currentPassword', label: 'Current Password', type: 'text', required: true },
509-
{ name: 'newPassword', label: 'New Password', type: 'text', required: true },
508+
{ name: 'currentPassword', label: 'Current Password', type: 'password', required: true },
509+
{ name: 'newPassword', label: 'New Password', type: 'password', required: true },
510510
{ name: 'revokeOtherSessions', label: 'Sign out other devices', type: 'boolean', required: false, defaultValue: true },
511511
],
512512
},
@@ -559,7 +559,7 @@ export const SysUser = ObjectSchema.create({
559559
successMessage: 'Account deleted',
560560
refreshAfter: false,
561561
params: [
562-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
562+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
563563
],
564564
},
565565
// ── Two-factor authentication ─────────────────────────────────
@@ -594,7 +594,7 @@ export const SysUser = ObjectSchema.create({
594594
requiresFeature: 'twoFactor',
595595
refreshAfter: true,
596596
params: [
597-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
597+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
598598
],
599599
// No `successMessage`: `resultDialog` suppresses it, so one declared
600600
// here would be unreachable text that still ships to every translator.
@@ -623,7 +623,7 @@ export const SysUser = ObjectSchema.create({
623623
successMessage: 'Two-factor authentication disabled.',
624624
refreshAfter: true,
625625
params: [
626-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
626+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
627627
],
628628
},
629629
{
@@ -643,7 +643,7 @@ export const SysUser = ObjectSchema.create({
643643
description: 'Generate a new set of backup codes? Any previously generated codes will stop working.',
644644
refreshAfter: false,
645645
params: [
646-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
646+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
647647
],
648648
// No `successMessage` — see the note above `enable_two_factor`. The toast
649649
// this replaces ("New backup codes generated — save them somewhere safe")

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
SysUserPreference,
2626
SysVerification,
2727
} from './identity/index.js';
28+
import * as identityObjects from './identity/index.js';
2829
// RBAC objects (SysRole/SysPermissionSet/… + defaultPermissionSets) moved to
2930
// @objectstack/plugin-security and the sharing objects to
3031
// @objectstack/plugin-sharing per ADR-0029 K2 — see their packages' tests.
@@ -94,6 +95,64 @@ describe('@objectstack/platform-objects', () => {
9495
});
9596
});
9697

98+
describe('credential action params declare `password`, so the dialog masks what is typed', () => {
99+
// An action param's `type` picks the console dialog's widget and nothing
100+
// else: no server path reads it (the endpoints take the posted string as
101+
// before), and the read mask above keys on FIELDS, never on params. A
102+
// `text` param renders `<input type="text">`, so a typed password is
103+
// readable on screen and in screenshots; `password` renders the masked
104+
// input. The roster pins each param by name; the sweep over every identity
105+
// object fails on a credential-named param added later as `text`.
106+
const roster: Array<[string, string, string, { name: string; actions?: readonly unknown[] }]> = [
107+
['SysUser', 'create_user', 'password', SysUser],
108+
['SysUser', 'set_user_password', 'newPassword', SysUser],
109+
['SysUser', 'change_my_password', 'currentPassword', SysUser],
110+
['SysUser', 'change_my_password', 'newPassword', SysUser],
111+
['SysUser', 'delete_my_account', 'password', SysUser],
112+
['SysUser', 'enable_two_factor', 'password', SysUser],
113+
['SysUser', 'disable_two_factor', 'password', SysUser],
114+
['SysUser', 'generate_backup_codes', 'password', SysUser],
115+
['SysTwoFactor', 'enable_two_factor', 'password', SysTwoFactor],
116+
['SysTwoFactor', 'disable_two_factor', 'password', SysTwoFactor],
117+
['SysTwoFactor', 'regenerate_backup_codes', 'password', SysTwoFactor],
118+
['SysSsoProvider', 'register_sso_provider', 'clientSecret', SysSsoProvider],
119+
];
120+
121+
type Param = { name?: string; type?: string };
122+
type Action = { name?: string; params?: readonly Param[] };
123+
const actionsOf = (object: unknown): readonly Action[] =>
124+
((object as { actions?: readonly Action[] }).actions ?? []);
125+
126+
it.each(roster)('%s.%s#%s declares `password`', (_exportName, actionName, paramName, object) => {
127+
const action = actionsOf(object).find((a) => a.name === actionName);
128+
const param = (action?.params ?? []).find((p) => p.name === paramName);
129+
expect(param, `${object.name}.${actionName} must declare a '${paramName}' param`).toBeDefined();
130+
expect(param?.type).toBe('password');
131+
});
132+
133+
it('every credential-named action param on an identity object declares `password`', () => {
134+
const credential = /password|secret/i;
135+
const seen: string[] = [];
136+
const offenders: string[] = [];
137+
for (const object of Object.values(identityObjects)) {
138+
const objectName = (object as { name?: unknown }).name;
139+
if (typeof objectName !== 'string') continue;
140+
for (const action of actionsOf(object)) {
141+
for (const param of action.params ?? []) {
142+
// A boolean toggle about a password (`mustChangePassword`) is no credential.
143+
if (!param.name || !credential.test(param.name) || param.type === 'boolean') continue;
144+
const key = `${objectName}.${action.name}#${param.name}`;
145+
seen.push(key);
146+
if (param.type !== 'password') offenders.push(`${key}: '${String(param.type)}'`);
147+
}
148+
}
149+
}
150+
// Positive control: the sweep reaches exactly the rostered params.
151+
expect(seen.sort()).toEqual(roster.map(([, a, p, o]) => `${o.name}.${a}#${p}`).sort());
152+
expect(offenders).toEqual([]);
153+
});
154+
});
155+
97156
describe('secure-by-default posture (ADR-0066 ④)', () => {
98157
// Raw secret / live-credential stores are opted OUT of the wildcard `'*'`
99158
// grant: only an explicit per-object grant or the posture-gated superuser

0 commit comments

Comments
 (0)