feat(acl): tree-based access editor + permission-aware UI
Some checks failed
build-and-push / build (push) Failing after 1h0m8s

Admin — Access Control is now a folder/file tree. Pick a user/group, click a
node to set an allow and/or deny rule there; each node shows the subject's
*effective* rights (letters S V R E C D M) and allow/deny "set here" badges,
computed server-side incl. group membership, inherited folder rules and
deny-wins. New queries: myAccess(paths) and subjectAccess(subjectType,subjectId,paths).

Main UI — actions are hidden when the current (non-admin) user lacks the
permission: create folder/document (Sidebar), drag-to-move, and Save/Delete in
the document view. Adds a permission-gated Delete button to the document
toolbar. Admins bypass and see everything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:28:16 +02:00
parent d4cea04941
commit 9180033543
7 changed files with 436 additions and 84 deletions

View File

@@ -225,6 +225,13 @@ func (s *Server) dispatchAuthenticated(
q := req.Query
switch {
// ── Effective-access queries (matched before generic acl/subject cases) ────
case strings.Contains(q, "myAccess"):
s.handleMyAccess(w, req, sess)
case strings.Contains(q, "subjectAccess"):
s.handleSubjectAccess(w, req, sess)
// ── Admin: OIDC / groups / membership / login gate (matched first) ─────────
case strings.Contains(q, "updateOidcConfig"):
s.handleUpdateOidcConfig(w, req, sess)
@@ -2542,3 +2549,82 @@ func boolVal(m map[string]interface{}, key string) bool {
b, _ := m[key].(bool)
return b
}
func strSlice(m map[string]interface{}, key string) []string {
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func permMap(path string, p db.Perms) map[string]interface{} {
return map[string]interface{}{
"path": path,
"canSearch": p.Search,
"canView": p.View,
"canRead": p.Read,
"canEdit": p.Edit,
"canCreate": p.Create,
"canDelete": p.Delete,
"canMove": p.Move,
}
}
// handleMyAccess returns the current session's effective permissions for each
// requested path. Used by the UI to hide actions the user cannot perform.
func (s *Server) handleMyAccess(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
paths := strSlice(req.Variables, "paths")
out := make([]map[string]interface{}, 0, len(paths))
for _, p := range paths {
out = append(out, permMap(p, s.access(sess, p)))
}
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"myAccess": out}})
}
// handleSubjectAccess returns the effective permissions a given subject
// (user or group) has for each requested path — for the admin access tree.
// For a user this includes their group memberships; for a group it is the
// group's own rules. Both walk ancestor folders with deny-wins.
func (s *Server) handleSubjectAccess(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
database := s.db()
if database == nil {
writeGQLError(w, "database not initialised")
return
}
subjectType := strVal(req.Variables, "subjectType")
idF, ok := req.Variables["subjectId"].(float64)
if !ok {
writeGQLError(w, "missing subjectId")
return
}
paths := strSlice(req.Variables, "paths")
var username string
var groups []string
if subjectType == "group" {
groups = []string{database.GroupNameByID(int64(idF))}
} else {
username = database.UsernameByID(int64(idF))
groups, _ = database.GetUserGroupNames(username)
}
out := make([]map[string]interface{}, 0, len(paths))
for _, p := range paths {
out = append(out, permMap(p, database.EffectiveAccess(username, groups, p)))
}
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"subjectAccess": out}})
}