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 +4 -1
- package/client/bundle.js +32 -12
- package/client/index.js +32 -12
- package/package.json +1 -1
- package/src/index.js +36 -15
- package/src/permissions.js +34 -11
- package/src/store.js +31 -3
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
87
87
|
|---|---|
|
|
88
88
|
| 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
|
|
89
89
|
| 所属部门 | 账号资料字段(trim 后 ≤ 64 字,控制字符剔除):新增用户时可填,编辑页可随时改或清空;变更写进操作日志 |
|
|
90
|
-
| 工作区白名单 |
|
|
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
|
-
|
|
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 =
|
|
386
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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
|
|
905
|
-
* workspace
|
|
906
|
-
*
|
|
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('
|
|
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
|
-
|
|
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 =
|
|
376
|
-
|
|
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
|
-
|
|
873
|
-
|
|
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
|
|
895
|
-
* workspace
|
|
896
|
-
*
|
|
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('
|
|
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
|
+
"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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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,
|
package/src/permissions.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
* empty list
|
|
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
|
|
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
|
|
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
|
-
|
|
73
|
+
const list = Array.isArray(allowedFolders) ? allowedFolders : []
|
|
74
|
+
return !list.some(entryAllowsEveryWorkspace)
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
/**
|
|
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
|
|
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
|
|
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,
|