feat(acl): tree-based access editor + permission-aware UI
Some checks failed
build-and-push / build (push) Failing after 1h0m8s
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:
@@ -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}})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user