diff --git a/uds-auth/.gitignore b/uds-auth/.gitignore index ccd96f90..dec3e520 100644 --- a/uds-auth/.gitignore +++ b/uds-auth/.gitignore @@ -6,3 +6,5 @@ config.runtime.json *.log .DS_Store Thumbs.db +session-owners.json +user-workspaces.json diff --git a/uds-auth/config.default.yaml b/uds-auth/config.default.yaml index 3f2b2d76..d69c0180 100644 --- a/uds-auth/config.default.yaml +++ b/uds-auth/config.default.yaml @@ -12,3 +12,6 @@ loginSystemCode: '100000455558' # 登录时传给 UAC 的来源系统 Code (留空则不传) originSystemCode: '' + +# Per-user workspaces under workspaceRoot/; empty => $DSH_HOME/user-workspaces +workspaceRoot: "" diff --git a/uds-auth/cordis.patch.yml b/uds-auth/cordis.patch.yml index 9136744a..24e98974 100644 --- a/uds-auth/cordis.patch.yml +++ b/uds-auth/cordis.patch.yml @@ -8,3 +8,4 @@ userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search loginSystemCode: '100000455558' originSystemCode: '' + workspaceRoot: '' diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index 40393781..88e33028 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -20,6 +20,7 @@ window.__ModuleLoader__.load({ const CSS = [ '.uds-auth-host{position:relative;display:inline-flex;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto}.uds-auth-host.is-rail{justify-content:center;width:100%}[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px;width:100%}[data-uds-auth-foot="row"]>*:nth-child(1){order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}[data-uds-auth-foot="row"]>*:nth-child(2){order:1;flex:none!important;width:auto!important;min-width:0}', + 'html[data-uds-can-settings="0"] [data-uds-auth-foot="row"]>*:not(:has([data-uds-auth-host])){display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}', '.uds-auth-badge{display:inline-flex;align-items:center;justify-content:center;gap:8px;max-width:min(180px,40vw);min-width:0;height:42px;padding:0 10px 0 8px;box-sizing:border-box;border:none;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;font-weight:400;line-height:22px;cursor:pointer;overflow:hidden}', '.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}', '.uds-auth-host.is-rail .uds-auth-badge{width:36px;height:36px;padding:0;border-radius:50%;gap:0}', @@ -280,6 +281,7 @@ window.__ModuleLoader__.load({ userSearchUrl: '', loginSystemCode: '', originSystemCode: '', + workspaceRoot: '', }) const [fallbackEnabled, setFallbackEnabled] = useState(false) const [fallbackPwd, setFallbackPwd] = useState('') @@ -360,6 +362,7 @@ window.__ModuleLoader__.load({ field('userSearchUrl', '\u7528\u6237\u641c\u7d22 URL\uff08token \u6821\u9a8c\uff09'), field('loginSystemCode', 'loginSystemCode'), field('originSystemCode', 'originSystemCode'), + field('workspaceRoot', '工作区根目录(空=$DSH_HOME/user-workspaces)'), h('div', { className: 'uds-auth-settings-actions' }, h('button', { type: 'button', @@ -457,6 +460,18 @@ window.__ModuleLoader__.load({ const [fbErr, setFbErr] = useState('') const qrRef = useRef({ key: null, value: null, timer: null }) + useEffect(() => { + const perms = user?.permissions || {} + const canSettings = user ? !!perms.canAccessSettings : false + const canCreateWs = user ? !!perms.canCreateWorkspace : false + document.documentElement.setAttribute('data-uds-can-settings', canSettings ? '1' : '0') + document.documentElement.setAttribute('data-uds-can-create-ws', canCreateWs ? '1' : '0') + return () => { + document.documentElement.removeAttribute('data-uds-can-settings') + document.documentElement.removeAttribute('data-uds-can-create-ws') + } + }, [user]) + const stopQr = useCallback(() => { if (qrRef.current.timer) { clearInterval(qrRef.current.timer); qrRef.current.timer = null } qrRef.current.key = null @@ -748,6 +763,37 @@ window.__ModuleLoader__.load({ document.head.appendChild(tag) return () => tag.remove() }, 'uds-auth: styles') + + ctx.effect(() => { + let settingsGateDispose = null + const syncSettingsGate = async () => { + let canSettings = false + try { + const me = await fetchJson('/uds-auth/api/me') + canSettings = !!(me?.data?.permissions?.canAccessSettings) + } catch { canSettings = false } + if (canSettings) { + if (settingsGateDispose) { try { settingsGateDispose() } catch {} settingsGateDispose = null } + return + } + if (settingsGateDispose) return + settingsGateDispose = ctx.slots.register({ + name: 'sidebar.settings', + id: 'uds-auth-settings-gate', + order: 9999, + }, function UdsAuthSettingsGate() { return null }) + } + syncSettingsGate() + const mePoll = setInterval(syncSettingsGate, 15000) + const onFocus = () => { syncSettingsGate() } + window.addEventListener('focus', onFocus) + return () => { + clearInterval(mePoll) + window.removeEventListener('focus', onFocus) + if (settingsGateDispose) { try { settingsGateDispose() } catch {} } + } + }, 'uds-auth: settings-gate') + // sidebar.footer.action; layout effect lays footArea as one row: Settings | UDS login ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js new file mode 100644 index 00000000..3d4a47da --- /dev/null +++ b/uds-auth/lib/dsh-acl.js @@ -0,0 +1,352 @@ +/** + * Bridge UDS cookies → AsyncLocalStorage and wrap DSH session/workspace/settings. + */ +import { withUserContext, getUserContext } from './context.js' +import { computePermissions, ROLES } from './roles.js' + +function parseCookie(header, name) { + if (!header || typeof header !== 'string') return null + for (const part of header.split(';')) { + const idx = part.indexOf('=') + if (idx < 0) continue + if (part.slice(0, idx).trim() !== name) continue + try { + return decodeURIComponent(part.slice(idx + 1).trim()) + } catch { + return part.slice(idx + 1).trim() + } + } + return null +} + +/** + * @param {import('node:http').IncomingMessage} req + * @param {{ sessionStore: any, rolesStore: any }} deps + */ +export async function resolveIdentityFromRequest(req, deps) { + const cookie = req?.headers?.cookie || '' + const empNo = parseCookie(cookie, 'PORTALSSOUser') + || parseCookie(cookie, 'ZTEDPGSSOUser') + || parseCookie(cookie, 'UDS_FALLBACK_USER') + if (!empNo) return null + + const { sessionStore, rolesStore } = deps + let userContext = null + try { + userContext = await sessionStore.get(empNo) + } catch { + userContext = null + } + if (!userContext) return null + + // bootstrap / resolve role + let role = rolesStore.getRole(empNo) + if (empNo !== 'administrator' && typeof rolesStore.bootstrapFirstUser === 'function') { + try { + const r = await rolesStore.bootstrapFirstUser(empNo) + role = r.role + } catch { /* keep getRole */ } + } + + const permissions = computePermissions(role) + return { + empNo: String(empNo), + role, + permissions, + userContext, + kind: empNo === 'administrator' ? 'fallback' : 'uds', + } +} + +/** + * Patch webServer so every route handler runs inside UDS ALS. + * @param {any} server + * @param {(req: any) => Promise} resolveIdentity + */ +export function patchWebServerWithIdentity(server, resolveIdentity) { + if (!server || server.__udsAuthPatched) return () => {} + server.__udsAuthPatched = true + + const wrap = (handler) => { + if (typeof handler !== 'function' || handler.__udsWrapped) return handler + const wrapped = async (req, res, ...rest) => { + const identity = await resolveIdentity(req) + return withUserContext(identity, () => handler(req, res, ...rest)) + } + wrapped.__udsWrapped = true + return wrapped + } + + const patchTable = (table) => { + if (!table || typeof table.entries !== 'function') return + for (const [path, route] of table.entries()) { + if (!route?.handler) continue + table.set(path, { ...route, handler: wrap(route.handler) }) + } + } + + patchTable(server.exact) + patchTable(server.prefixes) + if (typeof server.fallback === 'function') { + server.fallback = wrap(server.fallback) + } + + const origRegister = server.register.bind(server) + server.register = (route) => origRegister({ + ...route, + handler: wrap(route.handler), + }) + + const origFallback = server.registerFallback?.bind(server) + if (origFallback) { + server.registerFallback = (handler) => origFallback(wrap(handler)) + } + + return () => { + /* leave patched — reload recreates webServer fiber */ + } +} + +function throwForbidden(message) { + try { + const { RemoteError } = require('@deepseek-ai/dsh-typert-protocol') + throw new RemoteError('gateway/forbidden', message || 'forbidden', {}) + } catch (err) { + if (err && (err.name === 'RemoteError' || String(err.code || '').startsWith('gateway/'))) throw err + const e = new Error(message || 'forbidden') + e.code = 'gateway/forbidden' + throw e + } +} + +/** + * Install Host ACL wrappers. + */ +export function installDshAcl(ctx, { + sessionAcl, + userWorkspaces, + getWorkspaceRoot, + rolesStore, + ensureUserWorkspace, +}) { + const disposers = [] + + // Stamp owner on session create + const offCreated = ctx.on('session/created', (session) => { + try { + const id = session?.id ?? session?.header?.id + const identity = getUserContext() + if (id && identity?.empNo) { + sessionAcl.setOwner(id, identity.empNo) + } + } catch (err) { + ctx.logger?.warn?.('[uds-auth] session stamp failed: %s', err.message) + } + }) + disposers.push(() => offCreated?.()) + + ctx.inject(['sessionController'], (sctx) => { + const sc = sctx.sessionController + if (!sc || sc.__udsAcl) return + sc.__udsAcl = true + + const origList = sc.list.bind(sc) + sc.list = async (request, signal) => { + const value = await origList(request, signal) + const identity = getUserContext() + if (!identity?.empNo) return { items: [] } + if (identity.permissions?.canViewAllSessions) return value + return sessionAcl.filterListValue(value) + } + + const origSearch = sc.search.bind(sc) + sc.search = async (request, signal) => { + const value = await origSearch(request, signal) + const identity = getUserContext() + if (!identity?.empNo) return { items: [], hasMore: false } + if (identity.permissions?.canViewAllSessions) return value + return sessionAcl.filterListValue(value) + } + + const origCreate = sc.create.bind(sc) + sc.create = async (request) => { + const identity = getUserContext() + if (!identity?.empNo) throwForbidden('登录后才能创建会话') + + let req = request || {} + if (req.workspaceId === undefined && req.cwd === undefined) { + const ensured = await ensureUserWorkspace(identity.empNo) + if (ensured?.workspaceId) { + req = { ...req, workspaceId: ensured.workspaceId } + } else if (ensured?.path) { + req = { ...req, cwd: ensured.path } + } + } + + const result = await origCreate(req) + const sid = result?.sessionId ?? result?.id + if (sid) sessionAcl.setOwner(sid, identity.empNo) + return result + } + const assertCanView = (sessionId) => { + const identity = getUserContext() + if (!identity?.empNo) throwForbidden('登录后才能访问会话') + if (identity.permissions?.canViewAllSessions) return + if (!sessionAcl.canViewSession(sessionId)) throwForbidden('无权访问该会话') + } + + if (typeof sc.page === 'function') { + const origPage = sc.page.bind(sc) + sc.page = async (request, signal) => { + assertCanView(request?.sessionId ?? request?.id) + return origPage(request, signal) + } + } + + if (typeof sc.follow === 'function') { + const origFollow = sc.follow.bind(sc) + sc.follow = (request, signal) => { + assertCanView(request?.sessionId ?? request?.id) + return origFollow(request, signal) + } + } + + if (typeof sc.fork === 'function') { + const origFork = sc.fork.bind(sc) + sc.fork = async (request, signal) => { + assertCanView(request?.sessionId ?? request?.id) + const result = await origFork(request, signal) + const identity = getUserContext() + const sid = result?.sessionId ?? result?.id + if (sid && identity?.empNo) sessionAcl.setOwner(sid, identity.empNo) + return result + } + } + + if (typeof sc.rename === 'function') { + const origRename = sc.rename.bind(sc) + sc.rename = async (request, signal) => { + assertCanView(request?.sessionId ?? request?.id) + return origRename(request, signal) + } + } + + }) + + ctx.inject(['workspaceController'], (wctx) => { + const wc = wctx.workspaceController + if (!wc || wc.__udsAcl) return + wc.__udsAcl = true + + const origCreate = wc.create.bind(wc) + wc.create = async (request) => { + const identity = getUserContext() + if (identity?._internalProvision) return origCreate(request) + if (!identity?.permissions?.canCreateWorkspace) { + throwForbidden('只有超级管理员可以创建工作区') + } + return origCreate(request) + } + + const filterWorkspace = (ws) => { + const identity = getUserContext() + if (!identity?.empNo) return false + if (identity.permissions?.canViewAllSessions) return true // super sees all workspaces + const root = getWorkspaceRoot() + return userWorkspaces.isUserPath(identity.empNo, ws?.path, root) + || (userWorkspaces.get(identity.empNo)?.workspaceId + && String(userWorkspaces.get(identity.empNo).workspaceId) === String(ws?.workspaceId)) + } + + const filterBaseline = (baseline) => { + if (!baseline?.items) return baseline + const items = baseline.items.filter(filterWorkspace) + const allowedIds = new Set(items.map((w) => String(w.workspaceId))) + return { + ...baseline, + items, + // keep archived as-is; session list ACL still applies + } + } + + const filterFrame = (frame) => { + if (!frame) return frame + if (frame.type === 'baseline') { + return { ...frame, value: filterBaseline(frame.value) } + } + if (frame.type === 'upsert') { + return filterWorkspace(frame.workspace) ? frame : null + } + if (frame.type === 'order') { + const identity = getUserContext() + if (identity?.permissions?.canViewAllSessions) return frame + const allowed = new Set( + (userWorkspaces.get(identity?.empNo)?.workspaceId + ? [String(userWorkspaces.get(identity.empNo).workspaceId)] + : []), + ) + // Also allow any path-matched ids from current list — order may include others; filter + return { + ...frame, + workspaceIds: (frame.workspaceIds || []).filter((id) => { + if (allowed.has(String(id))) return true + // unknown ids: drop for non-super + return false + }), + } + } + return frame + } + + const origFollow = wc.follow.bind(wc) + wc.follow = async function* (signal) { + for await (const frame of origFollow(signal)) { + const next = filterFrame(frame) + if (next) yield next + } + } + }) + + // settings mutate gate + ctx.inject(['settings'], (sctx) => { + const settings = sctx.settings + if (!settings || settings.__udsAcl) return + settings.__udsAcl = true + for (const method of ['mutate', 'update', 'replace', 'write']) { + if (typeof settings[method] !== 'function') continue + const orig = settings[method].bind(settings) + settings[method] = async (...args) => { + const identity = getUserContext() + // Allow uds-auth namespace writes from our own settings section for admins; + // users cannot touch any settings. + if (!identity?.permissions?.canAccessSettings) { + throwForbidden('当前账号无设置权限') + } + return orig(...args) + } + } + }) + + // directory picker: block createDirectory for non-super + ctx.inject(['directoryPicker'], (dctx) => { + const dp = dctx.directoryPicker + if (!dp || dp.__udsAcl) return + dp.__udsAcl = true + if (typeof dp.createDirectory === 'function') { + const orig = dp.createDirectory.bind(dp) + dp.createDirectory = async (...args) => { + const identity = getUserContext() + if (!identity?.permissions?.canCreateWorkspace) { + throwForbidden('只有超级管理员可以创建目录/工作区') + } + return orig(...args) + } + } + }) + + return () => { + for (const d of disposers) { + try { d() } catch { /* ignore */ } + } + } +} diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index e15c0795..b5f20b7e 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -61,6 +61,7 @@ const CONFIG_DEFAULTS = { userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search', loginSystemCode: '100000455558', originSystemCode: '', + workspaceRoot: '', } // 内部常量(不暴露给用户,UDS 固定协议) @@ -95,6 +96,7 @@ function loadConfigSchemaAndSettings(ctx) { userSearchUrl: z.string().default(CONFIG_DEFAULTS.userSearchUrl), loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode), originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode), + workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot), }) _DshSettings = require('@deepseek-ai/dsh-settings') ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded') @@ -116,6 +118,9 @@ let _apiHandlers = null let _rolesStore = null let _currentConfig = null let _qrcodeLib = null +let _sessionAcl = null +let _userWorkspaces = null +let _pluginCtx = null let _logger = console function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) { @@ -307,7 +312,8 @@ async function handleConfigGet(req, res) { userSearchUrl: c.userSearchUrl, loginSystemCode: c.loginSystemCode, originSystemCode: c.originSystemCode, - }, + workspaceRoot: c.workspaceRoot, + }, })) } @@ -440,6 +446,7 @@ async function handleFallbackLogin(req, res) { lastActiveAt: new Date().toISOString(), } await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) + await ensureUserWorkspace(empNo) // 给浏览器设 cookie,让后续请求 auth-middleware 能识别 res.setHeader('Set-Cookie', [ @@ -465,7 +472,7 @@ const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json') async function saveRuntimeConfig(partial) { if (!_currentConfig) throw new Error('配置未初始化') // 只允许修改 4 个可配置字段 - const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode'] + const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot'] for (const k of allowed) { if (partial[k] !== undefined) { _currentConfig[k] = String(partial[k]) @@ -546,6 +553,9 @@ async function handleAllRoutes(req, res) { function handleRequest(req, res) { const ctx2 = { req, res } _authMiddleware(ctx2, async () => { + if (ctx2.empNo && ctx2.empNo !== 'administrator') { + try { await ensureUserWorkspace(ctx2.empNo) } catch { /* ignore */ } + } // bootstrap 已在 auth-middleware.resolveRole 中完成(勿用 !ctx2.role,getRole 恒有值) const url = new URL(req.url, 'http://localhost').pathname.replace(API_PREFIX, '') || '/' @@ -662,6 +672,21 @@ function installSettingsSection(ctx, entry, hooks) { }) } + +async function ensureUserWorkspace(empNo) { + if (!_userWorkspaces || !_pluginCtx) return null + try { + return await _userWorkspaces.ensureUserWorkspace( + _pluginCtx, + _currentConfig?.workspaceRoot, + empNo, + ) + } catch (err) { + console.warn('[uds-auth] ensureUserWorkspace failed:', err.message) + return null + } +} + export async function apply(ctx, config = {}) { let source = () => mergeConfig(config) _currentConfig = source() @@ -698,10 +723,13 @@ async function initServices(ctx, config) { const { createAuthMiddleware } = await import('./middleware/auth-middleware.js') const { createApiHandlers } = await import('./api.js') const { RolesStore } = await import('./roles.js') - + const { SessionAclStore } = await import('./session-acl.js') + const { UserWorkspaceStore } = await import('./workspace-provision.js') + const { patchWebServerWithIdentity, resolveIdentityFromRequest, installDshAcl } = await import('./dsh-acl.js') + + _pluginCtx = ctx _currentConfig = config - // 加载运行时配置覆盖(如果存在) try { const raw = await readFile(RUNTIME_CONFIG_FILE, 'utf-8') const rt = JSON.parse(raw) @@ -710,29 +738,56 @@ async function initServices(ctx, config) { _currentConfig[k] = rt[k] } } - } catch { /* runtime config 不存在是正常的 */ } - + } catch { /* runtime config optional */ } + _sessionStore = await createSessionStore(INTERNAL.session) - // RolesStore — 持久化到 roles.json const rolesFile = resolve(__dirname, '..', 'roles.json') _rolesStore = new RolesStore({ rolesFile }) await _rolesStore.init() - + + _sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') }) + await _sessionAcl.init() + + _userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(__dirname, '..', 'user-workspaces.json') }) + await _userWorkspaces.init() + _authMiddleware = createAuthMiddleware({ - userSearchUrl: config.userSearchUrl, + userSearchUrl: _currentConfig.userSearchUrl, udsAuth: { - baseUrl: config.uacBaseUrl, - systemCode: config.loginSystemCode, - userSearchUrl: config.userSearchUrl, + baseUrl: _currentConfig.uacBaseUrl, + systemCode: _currentConfig.loginSystemCode, + userSearchUrl: _currentConfig.userSearchUrl, empNoHeader: INTERNAL.empNoHeader, authValueHeader: INTERNAL.authValueHeader, }, session: INTERNAL.session, }, _sessionStore, _rolesStore) _apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore) - - ctx.logger?.info?.('[uds-auth] Initialized') + + const resolveIdentity = (req) => resolveIdentityFromRequest(req, { + sessionStore: _sessionStore, + rolesStore: _rolesStore, + }) + + const patchServer = (server) => { + patchWebServerWithIdentity(server, resolveIdentity) + } + const present = tryGet(ctx, 'webServer') + if (present) patchServer(present) + ctx.inject(['webServer'], (wctx) => { + patchServer(wctx.webServer) + }) + + installDshAcl(ctx, { + sessionAcl: _sessionAcl, + userWorkspaces: _userWorkspaces, + getWorkspaceRoot: () => _currentConfig?.workspaceRoot, + rolesStore: _rolesStore, + ensureUserWorkspace, + }) + + ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces)') } catch (err) { ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err)) } diff --git a/uds-auth/lib/roles.js b/uds-auth/lib/roles.js index b8502e2a..4a6459d0 100644 --- a/uds-auth/lib/roles.js +++ b/uds-auth/lib/roles.js @@ -30,13 +30,33 @@ export const ROLE_LABELS = { export function computePermissions(role) { switch (role) { case ROLES.SUPER_ADMIN: - return { canManageUsers: true, canAccessSettings: true, canViewAllSessions: true } + return { + canManageUsers: true, + canAccessSettings: true, + canViewAllSessions: true, + canCreateWorkspace: true, + } case ROLES.FALLBACK_ADMIN: - return { canManageUsers: true, canAccessSettings: true, canViewAllSessions: true } + return { + canManageUsers: true, + canAccessSettings: true, + canViewAllSessions: true, + canCreateWorkspace: true, + } case ROLES.ADMIN: - return { canManageUsers: false, canAccessSettings: true, canViewAllSessions: false } + return { + canManageUsers: false, + canAccessSettings: true, + canViewAllSessions: false, + canCreateWorkspace: false, + } default: // user / undefined - return { canManageUsers: false, canAccessSettings: false, canViewAllSessions: false } + return { + canManageUsers: false, + canAccessSettings: false, + canViewAllSessions: false, + canCreateWorkspace: false, + } } } @@ -195,16 +215,9 @@ export class RolesStore { * 设置用户角色 * 保护性 invariant: 至少保留 1 个 super_admin */ - _assertAdmin(currentAdminRole) { - if (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) { - throw new Error('只有超级管理员可以执行此操作') - } - } - async setRole(empNo, newRole, currentAdminRole) { - this._assertAdmin(currentAdminRole) - if (newRole === ROLES.FALLBACK_ADMIN) { - throw new Error('不能将普通工号设为兜底管理员(请使用应急账号)') + if (currentAdminRole !== ROLES.SUPER_ADMIN) { + throw new Error('只有超级管理员可以修改角色') } // invariant: 不能让系统变成 0 个 super_admin @@ -223,7 +236,9 @@ export class RolesStore { /** 删除用户 */ async removeUser(empNo, currentAdminRole) { - this._assertAdmin(currentAdminRole) + if (currentAdminRole !== ROLES.SUPER_ADMIN) { + throw new Error('只有超级管理员可以删除用户') + } const currentRole = this._roles.get(empNo) if (currentRole === ROLES.SUPER_ADMIN) { const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN) @@ -238,7 +253,9 @@ export class RolesStore { /** 确保用户存在 (如果不存在设为 user) */ ensureUser(empNo, currentAdminRole) { - this._assertAdmin(currentAdminRole) + if (currentAdminRole !== ROLES.SUPER_ADMIN) { + throw new Error('只有超级管理员可以添加用户') + } if (!this._roles.has(empNo)) { this._roles.set(empNo, ROLES.USER) this._markDirty() @@ -249,7 +266,9 @@ export class RolesStore { // === Fallback Administrator === setFallbackPassword(password, currentAdminRole) { - this._assertAdmin(currentAdminRole) + if (currentAdminRole !== ROLES.SUPER_ADMIN) { + throw new Error('只有超级管理员可以设置兜底管理员密码') + } if (!password || password.length < 6) { throw new Error('密码至少 6 位') } @@ -259,7 +278,9 @@ export class RolesStore { } clearFallbackPassword(currentAdminRole) { - this._assertAdmin(currentAdminRole) + if (currentAdminRole !== ROLES.SUPER_ADMIN) { + throw new Error('只有超级管理员可以清除兜底管理员密码') + } this._fallbackPasswordHash = null this._markDirty() return true diff --git a/uds-auth/lib/session-acl.js b/uds-auth/lib/session-acl.js new file mode 100644 index 00000000..e818ef0c --- /dev/null +++ b/uds-auth/lib/session-acl.js @@ -0,0 +1,119 @@ +/** + * Session ownership ACL — sessionId → empNo, persisted beside roles.json. + */ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { getUserContext } from './context.js' +import { ROLES, computePermissions } from './roles.js' + +export class SessionAclStore { + /** + * @param {{ ownersFile?: string }} options + */ + constructor(options = {}) { + this._owners = new Map() + this._ownersFile = options.ownersFile ? resolve(options.ownersFile) : null + this._dirty = false + this._saveTimer = null + } + + async init() { + if (!this._ownersFile) return + try { + const raw = await readFile(this._ownersFile, 'utf-8') + const data = JSON.parse(raw) + for (const [sessionId, empNo] of Object.entries(data.owners || {})) { + this._owners.set(String(sessionId), String(empNo)) + } + } catch (err) { + if (err.code !== 'ENOENT') { + console.warn('[uds-auth:SessionAcl] load failed:', err.message) + } + } + } + + _markDirty() { + this._dirty = true + if (this._saveTimer) return + this._saveTimer = setTimeout(() => { void this._save() }, 1500) + } + + async _save() { + this._saveTimer = null + if (!this._dirty || !this._ownersFile) return + this._dirty = false + try { + await mkdir(dirname(this._ownersFile), { recursive: true }) + await writeFile( + this._ownersFile, + JSON.stringify({ + owners: Object.fromEntries(this._owners), + savedAt: new Date().toISOString(), + }, null, 2), + 'utf-8', + ) + } catch (err) { + console.warn('[uds-auth:SessionAcl] save failed:', err.message) + } + } + + getOwner(sessionId) { + return this._owners.get(String(sessionId)) || null + } + + setOwner(sessionId, empNo) { + if (!sessionId || !empNo) return + this._owners.set(String(sessionId), String(empNo)) + this._markDirty() + } + + /** + * Whether the current ALS identity may see this session. + * @param {string} sessionId + */ + canViewSession(sessionId) { + const ctx = getUserContext() + const perms = ctx?.permissions || computePermissions(ctx?.role) + if (perms.canViewAllSessions) return true + const owner = this.getOwner(sessionId) + if (!owner) { + // Orphan historical sessions: super_admin only (canViewAllSessions already true above) + return false + } + return !!ctx?.empNo && owner === String(ctx.empNo) + } + + /** + * Filter session list/search payloads shaped as { items: [...] }. + * @param {{ items?: Array<{ sessionId?: string }> }} value + */ + filterListValue(value) { + if (!value || !Array.isArray(value.items)) return value + return { + ...value, + items: value.items.filter((row) => { + const id = row?.sessionId ?? row?.id + return id != null && this.canViewSession(id) + }), + } + } +} + +/** + * Resolve identity for ACL checks (null = anonymous). + * @param {import('./roles.js').RolesStore} rolesStore + */ +export function identityFromAls(rolesStore) { + const ctx = getUserContext() + if (!ctx?.empNo) return null + const role = ctx.role || rolesStore.getRole(ctx.empNo) + return { + empNo: ctx.empNo, + role, + permissions: ctx.permissions || computePermissions(role), + } +} + +export function isSuperLike(role) { + return role === ROLES.SUPER_ADMIN || role === ROLES.FALLBACK_ADMIN +} diff --git a/uds-auth/lib/workspace-provision.js b/uds-auth/lib/workspace-provision.js new file mode 100644 index 00000000..7e4924db --- /dev/null +++ b/uds-auth/lib/workspace-provision.js @@ -0,0 +1,122 @@ +/** + * Per-user workspace provisioning under workspaceRoot/. + */ +import { mkdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { homedir } from 'node:os' +import { readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { withUserContext, getUserContext } from './context.js' + +function defaultWorkspaceRoot() { + const home = process.env.DSH_HOME || process.env.DSH_PROFILE_DIR || join(homedir(), '.dsh') + return join(home, 'user-workspaces') +} + +/** + * @param {string|undefined} configured + */ +export function resolveWorkspaceRoot(configured) { + const raw = (configured && String(configured).trim()) || defaultWorkspaceRoot() + return resolve(raw) +} + +export class UserWorkspaceStore { + /** + * @param {{ mapFile?: string }} options + */ + constructor(options = {}) { + this._map = new Map() // empNo → { path, workspaceId } + this._mapFile = options.mapFile ? resolve(options.mapFile) : null + this._dirty = false + this._saveTimer = null + } + + async init() { + if (!this._mapFile) return + try { + const raw = await readFile(this._mapFile, 'utf-8') + const data = JSON.parse(raw) + for (const [empNo, row] of Object.entries(data.users || {})) { + if (row?.path) this._map.set(String(empNo), { path: String(row.path), workspaceId: row.workspaceId || null }) + } + } catch (err) { + if (err.code !== 'ENOENT') console.warn('[uds-auth:UserWorkspace] load failed:', err.message) + } + } + + _markDirty() { + this._dirty = true + if (this._saveTimer) return + this._saveTimer = setTimeout(() => { void this._save() }, 1500) + } + + async _save() { + this._saveTimer = null + if (!this._dirty || !this._mapFile) return + this._dirty = false + try { + await mkdir(dirname(this._mapFile), { recursive: true }) + await writeFile( + this._mapFile, + JSON.stringify({ + users: Object.fromEntries(this._map), + savedAt: new Date().toISOString(), + }, null, 2), + 'utf-8', + ) + } catch (err) { + console.warn('[uds-auth:UserWorkspace] save failed:', err.message) + } + } + + get(empNo) { + return this._map.get(String(empNo)) || null + } + + set(empNo, row) { + this._map.set(String(empNo), row) + this._markDirty() + } + + /** + * Ensure directory + registry entry for empNo. + * @param {object} ctx - cordis context with workspaceRegistry + * @param {string} workspaceRoot + * @param {string} empNo + */ + async ensureUserWorkspace(ctx, workspaceRoot, empNo) { + if (!empNo || empNo === 'administrator') { + // Fallback admin: still get a dedicated folder + empNo = empNo || 'administrator' + } + const root = resolveWorkspaceRoot(workspaceRoot) + const userPath = join(root, String(empNo)) + await mkdir(userPath, { recursive: true }) + + const registry = ctx.workspaceRegistry || ctx.get?.('workspaceRegistry') + if (!registry || typeof registry.create !== 'function') { + console.warn('[uds-auth] workspaceRegistry unavailable; mkdir only:', userPath) + this.set(empNo, { path: userPath, workspaceId: null }) + return { path: userPath, workspaceId: null, workspace: null } + } + + const prev = getUserContext() + const workspace = await withUserContext( + { ...(prev || {}), empNo, _internalProvision: true }, + () => registry.create(userPath), + ) + const workspaceId = workspace?.id != null ? String(workspace.id) : null + this.set(empNo, { path: userPath, workspaceId }) + return { path: userPath, workspaceId, workspace } + } + + /** Path prefix check: is this path under the user's provisioned dir? */ + isUserPath(empNo, candidatePath, workspaceRoot) { + if (!empNo || !candidatePath) return false + const root = resolveWorkspaceRoot(workspaceRoot) + const userPath = resolve(join(root, String(empNo))) + const cand = resolve(String(candidatePath)) + return cand === userPath || cand.startsWith(userPath + sep) + } +}