kczx-user-management 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,7 +87,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
87
87
  |---|---|
88
88
  | 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
89
89
  | 所属部门 | 账号资料字段(trim 后 ≤ 64 字,控制字符剔除):新增用户时可填,编辑页可随时改或清空;变更写进操作日志 |
90
- | 工作区白名单 | 留空 = 允许全部;填写后只允许这些目录(Windows 路径折叠大小写,末尾斜杠自动忽略) |
90
+ | 工作区白名单 | **留空 = 不允许任何工作区**(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 `*` = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略 |
91
91
  | 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
92
92
  | 每小时 token 配额 | 由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截 |
93
93
  | 每日时长配额 | 按真实活跃时间累计(不是请求数),限流不因轮询膨胀 |
@@ -171,6 +171,9 @@ dsh plugin --profile web remove kczx-user-management
171
171
  - **执行只对本地存在的账号生效**:签名身份 / 未知用户名按「不限制」处理,避免迁移期误锁人。
172
172
  - **门只管走门的人**:若另有插件把 dsh 宿主绑到 `0.0.0.0`(例如 dsh-lan-access),直连宿主端口即可绕过登录,
173
173
  且**绕过流量不会出现在任何一本台账里**。要真正的强制约束,宿主端口必须保持回环。
174
+ - **工作区白名单的语义**:空名单 = 全部禁止(1.0.4 起;此前是"允许全部")。升级时会对老记录做**一次性迁移**——把"空名单"写成显式
175
+ 的 `*`(允许全部),保持账号原有的可达范围;迁移只跑一次(留下 `workspace-scope-migration.json` 标记),之后你手工清空白名单
176
+ 才等于全部禁止。新建账号默认空 = 无工作区权限。
174
177
  - **token 配额依赖客户端上报**:浏览器侧被禁用或页面未加载时不计费(时长配额不受影响,它在服务端计时)。
175
178
  - **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
176
179
  清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
package/client/bundle.js CHANGED
@@ -199,8 +199,11 @@ window.__ModuleLoader__.load({
199
199
  usageTokensFree: '{used} tok/h',
200
200
  permissionsTitle: '权限与配额',
201
201
  permFolders: '工作区白名单',
202
- permFoldersHint: '留空 = 允许访问所有工作区;填写后只允许这些目录下的工作区。',
203
- permFoldersAll: '未限制(允许全部工作区)',
202
+ permFoldersHint: '留空 = 不允许任何工作区;填写后只允许这些目录下的工作区;条目 * 表示允许全部。',
203
+ permFoldersNone: '未授权(不允许任何工作区)',
204
+ permFoldersAll: '允许全部(*)',
205
+ permFoldersAllShort: '允许全部工作区',
206
+ permFoldersNoneShort: '禁止所有工作区',
204
207
  permFolderPlaceholder: '例如 /srv/work 或 D:\\work',
205
208
  add: '添加',
206
209
  remove: '移除',
@@ -382,8 +385,11 @@ window.__ModuleLoader__.load({
382
385
  usageTokensFree: '{used} tok/h',
383
386
  permissionsTitle: 'Permissions & Quota',
384
387
  permFolders: 'Workspace allow-list',
385
- permFoldersHint: 'Empty = every workspace is allowed; once filled, only workspaces under these folders are.',
386
- permFoldersAll: 'Unrestricted (all workspaces allowed)',
388
+ permFoldersHint: 'Empty = no workspace at all; once filled, only workspaces under these folders are. Add * to allow every workspace.',
389
+ permFoldersNone: 'Not granted (no workspace allowed)',
390
+ permFoldersAll: 'Allow all (*)',
391
+ permFoldersAllShort: 'All workspaces',
392
+ permFoldersNoneShort: 'No workspace',
387
393
  permFolderPlaceholder: 'e.g. /srv/work or D:\\work',
388
394
  add: 'Add',
389
395
  remove: 'Remove',
@@ -874,14 +880,22 @@ window.__ModuleLoader__.load({
874
880
  return byTime || byTokens
875
881
  }
876
882
 
883
+ /** Whether one allow-list entry grants every workspace ('*', '/', '.' or ''). */
884
+ function folderAllowsEverything(entry) {
885
+ return entry === '*' || entry === '' || entry === '/' || entry === '.'
886
+ }
887
+
877
888
  /** One-line limits summary for the users table. */
878
889
  function permissionSummary(user, t) {
879
890
  const p = user.permissions || {}
880
891
  const bits = []
881
892
  if (p.banned) bits.push(t('permBannedShort'))
882
- if (Array.isArray(p.allowedFolders) && p.allowedFolders.length > 0) {
883
- bits.push(interpolate(t('permFoldersShort'), { n: p.allowedFolders.length }))
884
- }
893
+ // The workspace field always says something: an untouched account is denied,
894
+ // which the table must show rather than leave blank.
895
+ const folders = Array.isArray(p.allowedFolders) ? p.allowedFolders : []
896
+ if (folders.length === 0) bits.push(t('permFoldersNoneShort'))
897
+ else if (folders.some(folderAllowsEverything)) bits.push(t('permFoldersAllShort'))
898
+ else bits.push(interpolate(t('permFoldersShort'), { n: folders.length }))
885
899
  if (p.hourlyTokenLimit != null) bits.push(interpolate(t('permTokensShort'), { n: p.hourlyTokenLimit }))
886
900
  if (p.dailyMinuteLimit != null) bits.push(interpolate(t('permMinutesShort'), { n: p.dailyMinuteLimit }))
887
901
  if (p.sandboxMode) bits.push(p.sandboxMode)
@@ -901,9 +915,10 @@ window.__ModuleLoader__.load({
901
915
  * confirm: folding those into a save button would hide them from the operator
902
916
  * reviewing the form before pressing it.
903
917
  *
904
- * The store's reading is surfaced verbatim: an empty folder list means every
905
- * workspace, an empty limit means no cap clearing a field is a visible,
906
- * deliberate act rather than a silent default. */
918
+ * The store's reading is surfaced verbatim: an empty folder list means NO
919
+ * workspace (the whitelist denies by default, and '*' is the wide grant), an
920
+ * empty limit means no cap — clearing a field is a visible, deliberate act
921
+ * rather than a silent default. */
907
922
  function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
908
923
  const p = user.permissions || {}
909
924
  const isSelf = user.id === me.id
@@ -1058,7 +1073,7 @@ window.__ModuleLoader__.load({
1058
1073
  h('label', null, t('permFolders')),
1059
1074
  h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
1060
1075
  folders.length === 0
1061
- ? h('div', { className: 'um-empty' }, t('permFoldersAll'))
1076
+ ? h('div', { className: 'um-empty' }, t('permFoldersNone'))
1062
1077
  : folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
1063
1078
  h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
1064
1079
  h('button', {
@@ -1071,7 +1086,12 @@ window.__ModuleLoader__.load({
1071
1086
  value: draft, onChange: (e) => setDraft(e.target.value),
1072
1087
  onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
1073
1088
  }),
1074
- h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
1089
+ h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add')),
1090
+ // Blank now means "no workspace", so the wide grant needs its own door.
1091
+ h('button', {
1092
+ className: 'um-btn', style: { flex: 'none' },
1093
+ onClick: () => { if (!folders.includes('*')) setFolders([...folders, '*']) },
1094
+ }, t('permFoldersAll'))),
1075
1095
 
1076
1096
  h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
1077
1097
  h('input', {
package/client/index.js CHANGED
@@ -189,8 +189,11 @@ const ZH = {
189
189
  usageTokensFree: '{used} tok/h',
190
190
  permissionsTitle: '权限与配额',
191
191
  permFolders: '工作区白名单',
192
- permFoldersHint: '留空 = 允许访问所有工作区;填写后只允许这些目录下的工作区。',
193
- permFoldersAll: '未限制(允许全部工作区)',
192
+ permFoldersHint: '留空 = 不允许任何工作区;填写后只允许这些目录下的工作区;条目 * 表示允许全部。',
193
+ permFoldersNone: '未授权(不允许任何工作区)',
194
+ permFoldersAll: '允许全部(*)',
195
+ permFoldersAllShort: '允许全部工作区',
196
+ permFoldersNoneShort: '禁止所有工作区',
194
197
  permFolderPlaceholder: '例如 /srv/work 或 D:\\work',
195
198
  add: '添加',
196
199
  remove: '移除',
@@ -372,8 +375,11 @@ const EN = {
372
375
  usageTokensFree: '{used} tok/h',
373
376
  permissionsTitle: 'Permissions & Quota',
374
377
  permFolders: 'Workspace allow-list',
375
- permFoldersHint: 'Empty = every workspace is allowed; once filled, only workspaces under these folders are.',
376
- permFoldersAll: 'Unrestricted (all workspaces allowed)',
378
+ permFoldersHint: 'Empty = no workspace at all; once filled, only workspaces under these folders are. Add * to allow every workspace.',
379
+ permFoldersNone: 'Not granted (no workspace allowed)',
380
+ permFoldersAll: 'Allow all (*)',
381
+ permFoldersAllShort: 'All workspaces',
382
+ permFoldersNoneShort: 'No workspace',
377
383
  permFolderPlaceholder: 'e.g. /srv/work or D:\\work',
378
384
  add: 'Add',
379
385
  remove: 'Remove',
@@ -864,14 +870,22 @@ function overQuota(user) {
864
870
  return byTime || byTokens
865
871
  }
866
872
 
873
+ /** Whether one allow-list entry grants every workspace ('*', '/', '.' or ''). */
874
+ function folderAllowsEverything(entry) {
875
+ return entry === '*' || entry === '' || entry === '/' || entry === '.'
876
+ }
877
+
867
878
  /** One-line limits summary for the users table. */
868
879
  function permissionSummary(user, t) {
869
880
  const p = user.permissions || {}
870
881
  const bits = []
871
882
  if (p.banned) bits.push(t('permBannedShort'))
872
- if (Array.isArray(p.allowedFolders) && p.allowedFolders.length > 0) {
873
- bits.push(interpolate(t('permFoldersShort'), { n: p.allowedFolders.length }))
874
- }
883
+ // The workspace field always says something: an untouched account is denied,
884
+ // which the table must show rather than leave blank.
885
+ const folders = Array.isArray(p.allowedFolders) ? p.allowedFolders : []
886
+ if (folders.length === 0) bits.push(t('permFoldersNoneShort'))
887
+ else if (folders.some(folderAllowsEverything)) bits.push(t('permFoldersAllShort'))
888
+ else bits.push(interpolate(t('permFoldersShort'), { n: folders.length }))
875
889
  if (p.hourlyTokenLimit != null) bits.push(interpolate(t('permTokensShort'), { n: p.hourlyTokenLimit }))
876
890
  if (p.dailyMinuteLimit != null) bits.push(interpolate(t('permMinutesShort'), { n: p.dailyMinuteLimit }))
877
891
  if (p.sandboxMode) bits.push(p.sandboxMode)
@@ -891,9 +905,10 @@ function permissionSummary(user, t) {
891
905
  * confirm: folding those into a save button would hide them from the operator
892
906
  * reviewing the form before pressing it.
893
907
  *
894
- * The store's reading is surfaced verbatim: an empty folder list means every
895
- * workspace, an empty limit means no cap clearing a field is a visible,
896
- * deliberate act rather than a silent default. */
908
+ * The store's reading is surfaced verbatim: an empty folder list means NO
909
+ * workspace (the whitelist denies by default, and '*' is the wide grant), an
910
+ * empty limit means no cap — clearing a field is a visible, deliberate act
911
+ * rather than a silent default. */
897
912
  function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
898
913
  const p = user.permissions || {}
899
914
  const isSelf = user.id === me.id
@@ -1048,7 +1063,7 @@ function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
1048
1063
  h('label', null, t('permFolders')),
1049
1064
  h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
1050
1065
  folders.length === 0
1051
- ? h('div', { className: 'um-empty' }, t('permFoldersAll'))
1066
+ ? h('div', { className: 'um-empty' }, t('permFoldersNone'))
1052
1067
  : folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
1053
1068
  h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
1054
1069
  h('button', {
@@ -1061,7 +1076,12 @@ function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
1061
1076
  value: draft, onChange: (e) => setDraft(e.target.value),
1062
1077
  onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
1063
1078
  }),
1064
- h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
1079
+ h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add')),
1080
+ // Blank now means "no workspace", so the wide grant needs its own door.
1081
+ h('button', {
1082
+ className: 'um-btn', style: { flex: 'none' },
1083
+ onClick: () => { if (!folders.includes('*')) setFolders([...folders, '*']) },
1084
+ }, t('permFoldersAll'))),
1065
1085
 
1066
1086
  h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
1067
1087
  h('input', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kczx-user-management",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -163,13 +163,20 @@ function readJsonBody(req) {
163
163
  })
164
164
  }
165
165
 
166
- // Secure because the gateway is HTTPS-only now (the shared-server HTTP gate is gone).
167
- function sessionCookie(token) {
168
- return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${SESSION_TTL_SECONDS}`
166
+ /**
167
+ * Session cookie.
168
+ *
169
+ * `Secure` is set only when the listener actually terminates TLS: browsers
170
+ * silently DROP a Secure cookie received over plain HTTP, so a cleartext
171
+ * deployment keeping the flag would bounce the user back to the login page
172
+ * forever while every request looked fine on the server side.
173
+ */
174
+ function sessionCookie(token, secure = true) {
175
+ return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}; Max-Age=${SESSION_TTL_SECONDS}`
169
176
  }
170
177
 
171
- function clearedCookie() {
172
- return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`
178
+ function clearedCookie(secure = true) {
179
+ return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}; Max-Age=0`
173
180
  }
174
181
 
175
182
  /** Normalize an API path: strip trailing slashes (keep the root). */
@@ -200,6 +207,15 @@ async function handleApi(req, res, deps) {
200
207
  const method = (req.method || 'GET').toUpperCase()
201
208
  const { store } = deps
202
209
  const apiPath = path.startsWith(`${API_PREFIX}/`) ? path.slice(API_PREFIX.length) : path
210
+ /**
211
+ * Whether cookies should carry `Secure` — i.e. the listener terminates TLS.
212
+ *
213
+ * The transport lives in the host half's config, which handleApi cannot read
214
+ * directly (it is a module-level function; the config is apply()'s closure), so
215
+ * the answer arrives through deps. Defaulting to `true` keeps the strict
216
+ * behaviour for callers that do not supply it.
217
+ */
218
+ const secureCookies = () => (typeof deps.secureCookies === 'function' ? deps.secureCookies() !== false : true)
203
219
 
204
220
  const authed = async () => {
205
221
  // A signature-bearing request from the co-located login gateway has no
@@ -263,7 +279,7 @@ async function handleApi(req, res, deps) {
263
279
  const { token } = await store.createSession(user)
264
280
  await store.touchLogin(user)
265
281
  await store.appendActivity({ type: 'login', username: user.username, userId: user.id, ip: deps.clientIp(req) })
266
- return sendJson(res, 200, { user: store.publicUser(user) }, { 'set-cookie': sessionCookie(token) })
282
+ return sendJson(res, 200, { user: store.publicUser(user) }, { 'set-cookie': sessionCookie(token, secureCookies()) })
267
283
  }
268
284
 
269
285
  // Self-registration is closed. Accounts are created by an administrator on the
@@ -295,7 +311,7 @@ async function handleApi(req, res, deps) {
295
311
  await store.touchLogin(user)
296
312
  if (key && typeof key.consume === 'function') key.consume()
297
313
  await store.appendActivity({ type: 'setup', username: user.username, userId: user.id, ip: deps.clientIp(req), detail: 'owner account created' })
298
- return sendJson(res, 200, { user: store.publicUser(user) }, { 'set-cookie': sessionCookie(token) })
314
+ return sendJson(res, 200, { user: store.publicUser(user) }, { 'set-cookie': sessionCookie(token, secureCookies()) })
299
315
  } catch (error) {
300
316
  if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
301
317
  throw error
@@ -320,7 +336,7 @@ async function handleApi(req, res, deps) {
320
336
  await store.dropSession(session.token)
321
337
  await store.appendActivity({ type: 'logout', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
322
338
  }
323
- return sendJson(res, 200, { ok: true }, { 'set-cookie': clearedCookie() })
339
+ return sendJson(res, 200, { ok: true }, { 'set-cookie': clearedCookie(secureCookies()) })
324
340
  }
325
341
 
326
342
  // ── authenticated self service ───────────────────────────────────────────
@@ -1163,6 +1179,8 @@ const plugin = {
1163
1179
  */
1164
1180
  // Error → status mapping lives with the store's own error types.
1165
1181
  deps.statusForError = (error) => statusForStoreError(error)
1182
+ // Cookies follow the transport: Secure only when this listener speaks TLS.
1183
+ deps.secureCookies = () => resolvedConfig().plaintext !== true
1166
1184
  deps.workspaceOwners = () => store.workspaceOwners()
1167
1185
  deps.claimWorkspace = (path, username) => store.claimWorkspace(path, username)
1168
1186
  deps.releaseWorkspace = (path) => store.releaseWorkspace(path)
@@ -1201,15 +1219,16 @@ const plugin = {
1201
1219
  // sites WITH cert/key stay independent SNI sites (real domain
1202
1220
  // certs). Empty cfg.sites = pure auto (zero-config default).
1203
1221
  const sites = resolveSites(cfg, allLocalIPs())
1204
- // Plaintext is legal on loopback only: it exists so the co-located
1205
- // dsh-passwords gateway can reach this observer through the raw TCP
1206
- // tunnel it uses for WebSocket upgrades. Any other bind would expose
1207
- // the observer unencrypted, so it is refused rather than honoured.
1222
+ // Plaintext is honoured on any interface, because an intranet
1223
+ // deployment may legitimately want it but never quietly: every
1224
+ // password, session cookie and prompt crosses the wire unencrypted.
1225
+ // (Historically this was coerced to 127.0.0.1, when the only plaintext
1226
+ // listener was the loopback observer for dsh-passwords.)
1208
1227
  if (cfg.plaintext && !isLoopbackHost(cfg.listenHost)) {
1209
- warn(`user-management: plaintext listener pinned to 127.0.0.1 (configured listenHost "${cfg.listenHost}" would serve it unencrypted)`)
1228
+ warn(`user-management: PLAINTEXT gateway on ${cfg.listenHost}:${cfg.port} passwords, session cookies and all traffic are unencrypted. Use this only on a network you trust; prefer plaintext: false otherwise.`)
1210
1229
  }
1211
1230
  const options = {
1212
- listenHost: cfg.plaintext ? '127.0.0.1' : cfg.listenHost,
1231
+ listenHost: cfg.listenHost,
1213
1232
  plaintext: cfg.plaintext === true,
1214
1233
  port: cfg.port,
1215
1234
  upstream: resolveUpstream(cfg),
@@ -1227,7 +1246,9 @@ const plugin = {
1227
1246
  return renderLoginPage(params)
1228
1247
  },
1229
1248
  deps,
1230
- clearedCookie,
1249
+ // The gateway's own /logout route clears the session cookie; it must
1250
+ // follow the transport exactly like the API paths do.
1251
+ clearedCookie: () => clearedCookie(cfg.plaintext !== true),
1231
1252
  auditHooks,
1232
1253
  log,
1233
1254
  warn,
@@ -9,19 +9,26 @@
9
9
  * calls these; keeping them separate is what makes the policy verifiable
10
10
  * without a live gateway.
11
11
  *
12
- * Semantics are carried over verbatim, because the store and the settings UI
13
- * already speak them:
14
- * - an EMPTY allow-list means \"every workspace\" (the unrestricted default);
15
- * - DENY_ALL_WORKSPACES is how an admin says \"no workspace at all\", which an
16
- * empty list cannot express;
12
+ * Semantics (deliberately NOT the dsh-passwords reading any more):
13
+ * - an EMPTY allow-list means "no workspace at all" — the deny default. A
14
+ * field left blank must not be the widest possible grant;
15
+ * - ALL_WORKSPACES ('*') is how an admin grants every workspace, which an
16
+ * empty list used to express implicitly;
17
+ * - DENY_ALL_WORKSPACES ('__deny__') is the explicit deny sentinel, kept for
18
+ * records written under the old reading and for callers that want to say it
19
+ * out loud;
17
20
  * - a path is allowed when it equals an entry or sits underneath it.
18
21
  */
19
22
 
20
23
  import path from 'node:path'
21
24
 
22
- /** Sentinel entry meaning \"deny every workspace\" (distinct from \"[] = all\"). */
25
+ /** Sentinel entry meaning "deny every workspace" (kept for records written under the old reading). */
23
26
  const DENY_ALL_WORKSPACES = '__deny__'
24
27
 
28
+ /** Sentinel entry meaning "every workspace" — the mirror of the deny sentinel,
29
+ * and the only way to grant everything now that an empty list denies. */
30
+ const ALL_WORKSPACES = '*'
31
+
25
32
  /** Sandbox modes from narrowest to widest; the rank decides what is a
26
33
  * downgrade and what would be an escalation. */
27
34
  const SANDBOX_RANK = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
@@ -53,20 +60,34 @@ function normalizePath(p) {
53
60
  return n
54
61
  }
55
62
 
56
- /** Whether a list actually restricts anything (empty = unrestricted). */
63
+ /** Whether one entry grants the whole disk. */
64
+ function entryAllowsEveryWorkspace(entry) {
65
+ if (entry === ALL_WORKSPACES) return true
66
+ const base = normalizePath(entry)
67
+ // normalize('') is '.': an empty entry and '/' both mean "the whole disk".
68
+ return base === '.' || base === '/'
69
+ }
70
+
71
+ /** Whether a list restricts anything — which an empty list now does. */
57
72
  function isWorkspaceRestricted(allowedFolders) {
58
- return Array.isArray(allowedFolders) && allowedFolders.length > 0
73
+ const list = Array.isArray(allowedFolders) ? allowedFolders : []
74
+ return !list.some(entryAllowsEveryWorkspace)
59
75
  }
60
76
 
61
- /** Whether `target` is inside the allow-list (or the list is unrestricted). */
77
+ /**
78
+ * Whether `target` is inside the allow-list.
79
+ *
80
+ * An empty list allows NOTHING: the field is a whitelist, so leaving it blank
81
+ * grants no workspace rather than every workspace.
82
+ */
62
83
  function folderAllowed(target, allowedFolders) {
63
84
  const list = Array.isArray(allowedFolders) ? allowedFolders : []
64
- if (list.length === 0) return true
85
+ if (list.length === 0) return false
65
86
  if (list.includes(DENY_ALL_WORKSPACES)) return false
66
87
  const p = normalizePath(target)
67
88
  return list.some((entry) => {
89
+ if (entry === ALL_WORKSPACES) return true
68
90
  const base = normalizePath(entry)
69
- // normalize('') is '.': an empty entry and '/' both mean \"the whole disk\"
70
91
  if (base === '.' || base === '/') return true
71
92
  // Prefix match must respect a separator boundary, or '/srv/ab' would pass
72
93
  // an allow-list entry of '/srv/a'.
@@ -466,6 +487,8 @@ export {
466
487
  extractWorkspaceId,
467
488
  collectIdPathPairs,
468
489
  DENY_ALL_WORKSPACES,
490
+ ALL_WORKSPACES,
491
+ entryAllowsEveryWorkspace,
469
492
  SANDBOX_RANK,
470
493
  MAX_JSON_DEPTH,
471
494
  normalizePath,
package/src/store.js CHANGED
@@ -21,7 +21,7 @@ import { promisify } from 'node:util'
21
21
  import { join, resolve } from 'node:path'
22
22
  import { homedir } from 'node:os'
23
23
  import { generateSecret, verifyTotp } from './totp.js'
24
- import { DENY_ALL_WORKSPACES, isWorkspaceRestricted, normalizePath } from './permissions.js'
24
+ import { ALL_WORKSPACES, DENY_ALL_WORKSPACES, isWorkspaceRestricted, normalizePath } from './permissions.js'
25
25
  // Only used to verify hashes imported from dsh-passwords: its accounts are
26
26
  // bcrypt and a hash cannot be converted, so the record keeps its algorithm
27
27
  // marker and is transparently re-hashed to scrypt on the next sign-in.
@@ -68,7 +68,8 @@ const BCRYPT_ALGO = 'bcrypt'
68
68
  let bcryptFailureReported = false
69
69
 
70
70
  const DEFAULT_PERMISSIONS = Object.freeze({
71
- /** Absolute folders this account may touch; empty = unrestricted. */
71
+ /** Absolute folders this account may touch. Empty = no workspace at all
72
+ * (the whitelist denies by default); '*' = every workspace. */
72
73
  allowedFolders: [],
73
74
  /** Tokens per rolling hour; null = unlimited. */
74
75
  hourlyTokenLimit: null,
@@ -372,6 +373,32 @@ function createStore({ home, now = () => Date.now() } = {}) {
372
373
  // Records written before the permission model existed carry no block: fill
373
374
  // in the defaults in place so every reader sees one shape.
374
375
  for (const user of usersDoc.users) user.permissions = normalizePermissions(user.permissions)
376
+ // One-time semantics migration. An empty allow-list used to mean "every
377
+ // workspace"; it now means "no workspace". Every account still carrying the
378
+ // old reading is pinned to the explicit allow-all sentinel ONCE, so the rule
379
+ // flips without silently locking anyone out of the workspaces they can reach
380
+ // today. The marker file is what makes it one-time: an account left empty
381
+ // after the migration means exactly what it says.
382
+ const scopeMarker = join(dir, 'workspace-scope-migration.json')
383
+ const migratedBefore = await fsP.access(scopeMarker).then(() => true).catch(() => false)
384
+ if (!migratedBefore) {
385
+ const legacy = usersDoc.users.filter((user) => (user.permissions.allowedFolders || []).length === 0)
386
+ for (const user of legacy) {
387
+ user.permissions = { ...normalizePermissions(user.permissions), allowedFolders: [ALL_WORKSPACES] }
388
+ }
389
+ await fsP.writeFile(
390
+ scopeMarker,
391
+ JSON.stringify({ version: 2, migratedAt: new Date(now()).toISOString(), accounts: legacy.length }) + '\n',
392
+ 'utf8',
393
+ )
394
+ if (legacy.length > 0) {
395
+ await persistUsers()
396
+ console.warn(
397
+ `[user-management] an empty workspace allow-list now denies every workspace; ` +
398
+ `${legacy.length} account(s) that carried the old reading were pinned to "${ALL_WORKSPACES}" (allow all)`,
399
+ )
400
+ }
401
+ }
375
402
  if (!sessionsDoc.tokens || typeof sessionsDoc.tokens !== 'object') sessionsDoc.tokens = {}
376
403
  if (!Array.isArray(bansDoc.bans)) bansDoc.bans = []
377
404
  if (!usageDoc || typeof usageDoc.users !== 'object' || usageDoc.users === null) usageDoc = { users: {} }
@@ -1052,7 +1079,7 @@ function createStore({ home, now = () => Date.now() } = {}) {
1052
1079
  // users
1053
1080
  createUser, verifyLogin, checkLogin, setPassword, setRole, setDisabled, setDepartment, removeUser, touchLogin,
1054
1081
  listUsers, findUser, findUserByUsername, countAdmins, roleForNextRegistration, publicUser,
1055
- setPermissions, normalizePermissions, isWorkspaceRestricted, DENY_ALL_WORKSPACES, importUser,
1082
+ setPermissions, normalizePermissions, isWorkspaceRestricted, DENY_ALL_WORKSPACES, ALL_WORKSPACES, importUser,
1056
1083
  // totp
1057
1084
  startTotpSetup, activateTotp, disableTotp, verifyLoginOtp,
1058
1085
  // sessions
@@ -1080,6 +1107,7 @@ export {
1080
1107
  normalizePermissions,
1081
1108
  isWorkspaceRestricted,
1082
1109
  DENY_ALL_WORKSPACES,
1110
+ ALL_WORKSPACES,
1083
1111
  DEFAULT_PERMISSIONS,
1084
1112
  SANDBOX_MODES,
1085
1113
  dshHome,