feat: improve multi-user permission controls

This commit is contained in:
OpenClaw Agent
2026-09-22 02:02:10 +08:00
parent 29c528ed5a
commit d738fa41a3
3 changed files with 27 additions and 10 deletions
+7 -1
View File
@@ -96,8 +96,8 @@ func TestUserManagementAndPrivateHome(t *testing.T) {
// Re-enable and grant read-only access to a shared directory. // Re-enable and grant read-only access to a shared directory.
rules := []map[string]any{ rules := []map[string]any{
{"dir": "users/alice", "mask": "rwd"},
{"dir": "public", "mask": "r"}, {"dir": "public", "mask": "r"},
{"dir": "public/private", "mask": "r", "deny": true, "childrenOnly": true},
} }
updateBody, _ = json.Marshal(map[string]any{"username": "alice", "enabled": true, "rules": rules}) updateBody, _ = json.Marshal(map[string]any{"username": "alice", "enabled": true, "rules": rules})
req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody)) req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody))
@@ -114,6 +114,12 @@ func TestUserManagementAndPrivateHome(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, "public", "readme.txt"), []byte("ok"), 0644); err != nil { if err := os.WriteFile(filepath.Join(root, "public", "readme.txt"), []byte("ok"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := mgr.Accounts["alice"].MaskFor("users/alice"); got != auth.Read|auth.Write|auth.Delete {
t.Fatalf("home permission was not preserved: %v", got)
}
if got := mgr.Accounts["alice"].MaskFor("public/private/file.txt"); got != auth.None {
t.Fatalf("deny rule was not applied: %v", got)
}
token, err = auth.NewToken("alice", 30) token, err = auth.NewToken("alice", 30)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+8 -8
View File
@@ -26,8 +26,8 @@ table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 15p
<div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div> <div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div>
<div class="toolbar"> <div class="toolbar">
<button onclick="goUp()">上一级</button><button onclick="refresh()">刷新</button> <button onclick="goUp()">上一级</button><button onclick="refresh()">刷新</button>
<button onclick="newFolder()">新建文件夹</button><button onclick="newText()">新建文本</button> <button id="newFolderBtn" onclick="newFolder()">新建文件夹</button><button id="newTextBtn" onclick="newText()">新建文本</button>
<label><button>上传</button><input id="upload" type="file" multiple hidden></label> <label><button id="uploadBtn">上传</button><input id="upload" type="file" multiple hidden></label>
<input id="search" placeholder="搜索文件名,回车执行"> <input id="search" placeholder="搜索文件名,回车执行">
<button id="usersBtn" onclick="showUsers()" hidden>用户管理</button><button class="ghost" id="pwdBtn" onclick="changePassword()" hidden>修改密码</button> <button id="usersBtn" onclick="showUsers()" hidden>用户管理</button><button class="ghost" id="pwdBtn" onclick="changePassword()" hidden>修改密码</button>
</div> </div>
@@ -35,14 +35,14 @@ table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 15p
</main> </main>
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div> <div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
<script> <script>
const $=id=>document.getElementById(id);let current="",me=null; const $=id=>document.getElementById(id);let current="",me=null,currentMask="";
async function api(url,opt={}){const r=await fetch(url,opt);if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};throw Error(t)}return r.status===204?null:r.json()} async function api(url,opt={}){const r=await fetch(url,opt);if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};throw Error(t)}return r.status===204?null:r.json()}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]))} function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]))}
function fmt(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";if(n<1073741824)return (n/1048576).toFixed(1)+" MB";return (n/1073741824).toFixed(1)+" GB"} function fmt(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";if(n<1073741824)return (n/1048576).toFixed(1)+" MB";return (n/1073741824).toFixed(1)+" GB"}
async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:"",admin:false}};updateUser();current=me.home||"";await refresh()} async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:"",admin:false}};updateUser();current=me.home||"";await refresh()}
function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin;$("pwdBtn").hidden=!me.username||!me.managed} function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin;$("pwdBtn").hidden=!me.username||!me.managed}
async function refresh(){try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);render(data.files||[]);$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}} async function refresh(){try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);currentMask=data.currentMask||"";const writable=currentMask.includes("W");$("newFolderBtn").disabled=!writable;$("newTextBtn").disabled=!writable;$("uploadBtn").disabled=!writable;$("search").disabled=!data.currentMask?.includes("R");render(data.files||[]);$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}}
function render(items){const body=$("files");body.innerHTML="";if(!items.length){body.innerHTML='<tr><td colspan="4" class="empty">这里还没有文件</td></tr>';return}for(const f of items){const p=current?current+"/"+f.name:f.name;const row=document.createElement("tr");const icon=f.dir?"📁":"📄";const share=f.guestMask&&f.guestMask.includes("R")?'<button class="ghost" onclick="shareItem('+JSON.stringify(p)+')">分享</button>':"";row.innerHTML='<td><div class="name"><span>'+icon+'</span>'+ (f.dir?'<a href="#" onclick="openDir('+JSON.stringify(p)+');return false">'+esc(f.name)+'</a>':'<span>'+esc(f.name)+'</span>')+'</div></td><td class="meta">'+(f.dir?"—":fmt(f.size))+'</td><td class="meta">'+esc(f.modified||"")+'</td><td><div class="actions">'+(f.dir?'<button class="ghost" onclick="downloadDir('+JSON.stringify(p)+')">ZIP</button>':'<button class="ghost" onclick="downloadFile('+JSON.stringify(p)+')">下载</button>')+share+'<button class="ghost" onclick="renameItem('+JSON.stringify(p)+')">改名</button><button class="danger" onclick="removeItem('+JSON.stringify(p)+')">删除</button></div></td>';body.appendChild(row)}} function render(items){const body=$("files");body.innerHTML="";if(!items.length){body.innerHTML='<tr><td colspan="4" class="empty">这里还没有文件</td></tr>';return}for(const f of items){const p=current?current+"/"+f.name:f.name;const row=document.createElement("tr");const icon=f.dir?"📁":"📄";const share=f.guestMask&&f.guestMask.includes("R")?'<button class="ghost" onclick="shareItem('+JSON.stringify(p)+')">分享</button>':"";const canRead=(f.mask||"").includes("R"),canWrite=(f.mask||"").includes("W"),canDelete=(f.mask||"").includes("D");const actions=(canRead?(f.dir?'<button class="ghost" onclick="downloadDir('+JSON.stringify(p)+')">ZIP</button>':'<button class="ghost" onclick="downloadFile('+JSON.stringify(p)+')">下载</button>'):"")+(canRead?share:"")+(canWrite?'<button class="ghost" onclick="renameItem('+JSON.stringify(p)+')">改名</button>':"")+(canDelete?'<button class="danger" onclick="removeItem('+JSON.stringify(p)+')">删除</button>':"");row.innerHTML='<td><div class="name"><span>'+icon+'</span>'+ (f.dir&&canRead?'<a href="#" onclick="openDir('+JSON.stringify(p)+');return false">'+esc(f.name)+'</a>':'<span>'+esc(f.name)+'</span>')+'</div></td><td class="meta">'+(f.dir?"—":fmt(f.size))+'</td><td class="meta">'+esc(f.modified||"")+'</td><td><div class="actions">'+actions+'</div></td>';body.appendChild(row)}}
function openDir(p){current=p;refresh()}function goUp(){if(!current)return;const root=me.home||"";if(current===root)return;let p=current.split("/");p.pop();let n=p.join("/");if(root&&!(n===root||n.startsWith(root+"/")))n=root;current=n;refresh()} function openDir(p){current=p;refresh()}function goUp(){if(!current)return;const root=me.home||"";if(current===root)return;let p=current.split("/");p.pop();let n=p.join("/");if(root&&!(n===root||n.startsWith(root+"/")))n=root;current=n;refresh()}
function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")} function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")}
function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)} function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)}
@@ -64,10 +64,10 @@ $("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()})
async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<div style="padding:10px 0;border-bottom:1px solid #edf0f5"><b>'+esc(u.username)+'</b> · '+(u.enabled?"启用":"已禁用")+' · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="ghost" onclick="editUser('+JSON.stringify(u.username)+')">权限/状态</button> <button class="ghost" onclick="resetUser('+JSON.stringify(u.username)+')">重置密码</button> <button class="danger" onclick="deleteUser('+JSON.stringify(u.username)+')">删除</button>':"")+"</div>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){alert(e.message)}} async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<div style="padding:10px 0;border-bottom:1px solid #edf0f5"><b>'+esc(u.username)+'</b> · '+(u.enabled?"启用":"已禁用")+' · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="ghost" onclick="editUser('+JSON.stringify(u.username)+')">权限/状态</button> <button class="ghost" onclick="resetUser('+JSON.stringify(u.username)+')">重置密码</button> <button class="danger" onclick="deleteUser('+JSON.stringify(u.username)+')">删除</button>':"")+"</div>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){alert(e.message)}}
async function createUser(){try{await api("/chfs/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:$("nu").value,password:$("np").value})});showUsers()}catch(e){alert(e.message)}} async function createUser(){try{await api("/chfs/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:$("nu").value,password:$("np").value})});showUsers()}catch(e){alert(e.message)}}
async function resetUser(u){const p=prompt("为 "+u+" 设置新密码");if(!p)return;try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});alert("密码已更新")}catch(e){alert(e.message)}} async function resetUser(u){const p=prompt("为 "+u+" 设置新密码");if(!p)return;try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});alert("密码已更新")}catch(e){alert(e.message)}}
async function editUser(name){const d=await api("/chfs/users");const u=d.users.find(x=>x.username===name);if(!u)return;let rows=(u.rules||[]).map((r,i)=>'<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:8px 0"><span><b>'+esc(r.dir)+'</b> · '+esc(r.mask||"禁止")+'</span><button class="danger" onclick="removeRule('+i+')">移除</button></div>').join("");show('<h2>'+esc(u.username)+'</h2><div class="notice">主目录:'+esc(u.home)+'<br>权限:R 读取 · W 写入 · D 删除</div><label><input id="ue" type="checkbox" '+(u.enabled?"checked":"")+'> 账号启用</label><div class="field"><input id="rp" placeholder="目录,例如 public 或 shared/team"><select id="rm"><option value="r">只读</option><option value="rw">读写</option><option value="rwd">读写删</option><option value="">禁止</option></select><button onclick="addRule()">添加目录权限</button></div><div id="rules">'+rows+'</div><div class="row"><button class="ghost" onclick="showUsers()">取消</button><button onclick="saveUser('+JSON.stringify(u.username)+')">保存</button></div>');window._editUser=u;window._editRules=[...(u.rules||[])]} async function editUser(name){const d=await api("/chfs/users");const u=d.users.find(x=>x.username===name);if(!u)return;window._editUser=u;window._editRules=[...(u.rules||[])];show('<h2>'+esc(u.username)+'</h2><div class="notice">主目录:'+esc(u.home)+'<br>主目录固定为 RWD;额外规则用于开放其他目录或显式拒绝访问。</div><label><input id="ue" type="checkbox" '+(u.enabled?"checked":"")+'> 账号启用</label><div class="field"><input id="rp" placeholder="目录,例如 public 或 shared/team"><select id="rm"><option value="r">只读</option><option value="rw">读写</option><option value="rwd">读写删</option><option value="">禁止</option></select><label><input id="rd" type="checkbox"> 拒绝访问</label><label><input id="rc" type="checkbox"> 仅作用于目录内部,不改变目录本身</label><button onclick="addRule()">添加目录权限</button></div><div id="rules"></div><div class="row"><button class="ghost" onclick="showUsers()">取消</button><button onclick="saveUser('+JSON.stringify(u.username)+')">保存</button></div>');renderEditRules()}
function addRule(){const dir=$("rp").value.trim().replace(/^\/+|\/+$/g,"");if(!dir)return;window._editRules.push({dir,mask:$("rm").value});renderEditRules()} function addRule(){const dir=$("rp").value.trim().replace(/^\/+|\/+$/g,"");if(!dir)return;const rule={dir,mask:$("rm").value};if($("rd").checked)rule.deny=true;if($("rc").checked)rule.childrenOnly=true;window._editRules.push(rule);$("rp").value="";renderEditRules()}
function removeRule(i){window._editRules.splice(i,1);renderEditRules()} function removeRule(i){window._editRules.splice(i,1);renderEditRules()}
function renderEditRules(){$("rules").innerHTML=window._editRules.map((r,i)=>'<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:8px 0"><span><b>'+esc(r.dir)+'</b> · '+esc(r.mask||"禁止")+'</span><button class="danger" onclick="removeRule('+i+')">移除</button></div>').join("")} function renderEditRules(){$("rules").innerHTML=window._editRules.map((r,i)=>{const flags=(r.deny?"拒绝":"允许")+(r.childrenOnly?" · 仅子项":"");return '<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:8px 0"><span><b>'+esc(r.dir)+'</b> · '+esc(r.mask||"禁止")+' · '+flags+'</span><button class="danger" onclick="removeRule('+i+')">移除</button></div>'}).join("")}
async function saveUser(u){try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,enabled:$("ue").checked,rules:window._editRules})});showUsers()}catch(e){alert(e.message)}} async function saveUser(u){try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,enabled:$("ue").checked,rules:window._editRules})});showUsers()}catch(e){alert(e.message)}}
async function deleteUser(u){if(!confirm("删除用户 "+u+"?用户目录不会自动删除。"))return;try{await api("/chfs/users?username="+encodeURIComponent(u),{method:"DELETE"});showUsers()}catch(e){alert(e.message)}} async function deleteUser(u){if(!confirm("删除用户 "+u+"?用户目录不会自动删除。"))return;try{await api("/chfs/users?username="+encodeURIComponent(u),{method:"DELETE"});showUsers()}catch(e){alert(e.message)}}
init(); init();
+12 -1
View File
@@ -168,7 +168,18 @@ func (u *UserStore) Update(m *Manager, username, password string, enabled *bool,
a.Enabled = *enabled a.Enabled = *enabled
} }
if rules != nil { if rules != nil {
a.Rules = rules // A managed user's home is always their private working area. Keep the
// base RWD rule even when an administrator replaces the extra rules.
// Additional rules can grant or deny access elsewhere.
next := make([]PathRule, 0, len(rules)+1)
next = append(next, PathRule{Dir: a.Home, Mask: Read | Write | Delete})
for _, rule := range rules {
if strings.Trim(rule.Dir, "/") == strings.Trim(a.Home, "/") {
continue
}
next = append(next, rule)
}
a.Rules = next
} }
return u.Save(m) return u.Save(m)
} }