feat: enhance LDAP integration with new configuration options and browsing capabilities

This commit is contained in:
Björn Blomberg
2026-04-14 16:07:52 +02:00
parent d6fd50e511
commit 61f33d350a
6 changed files with 631 additions and 163 deletions

View File

@@ -31,14 +31,20 @@ type Query {
# List images next to a document.
images(slug: String!): [ImageFile!]!
# Browse LDAP tree (admin only)
ldapBrowse(url: String, adminUser: String, adminPass: String): LDAPTree!
# Fetch permissions for a path
acl(path: String!): [ACLEntry!]!
}
type Mutation {
# First-run setup.
setup(input: SetupInput!): Boolean!
# Test an LDAP configuration before saving.
testLdapConnection(input: LDAPInput!): LDAPTestResult!
# Test an LDAP configuration.
testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult!
# Authenticate and receive a bearer token.
login(username: String!, password: String!): String!
@@ -55,6 +61,13 @@ type Mutation {
# Modify LDAP Configuration (admin only).
updateLdapConfig(input: LDAPInput): Boolean!
# Sync users/groups from LDAP
importLdapSubject(type: String!, name: String!): Boolean!
# ACL Mutations
setAcl(input: ACLInput!): Boolean!
removeAcl(id: Int!): Boolean!
# Modify Storage Path (admin only).
updateStoragePath(path: String!): Boolean!
@@ -87,10 +100,27 @@ type AppConfig {
}
type LDAPConfig {
host: String!
port: Int!
baseDN: String!
bindDN: String!
url: String!
adminUser: String!
}
type LDAPTree {
users: [String!]!
groups: [String!]!
}
type ACLEntry {
id: Int!
path: String!
subjectType: String!
subjectId: Int!
canSearch: Boolean!
canView: Boolean!
canRead: Boolean!
canEdit: Boolean!
canCreate: Boolean!
canDelete: Boolean!
canMove: Boolean!
}
type ServerDirectory {
@@ -148,11 +178,22 @@ input SetupInput {
}
input LDAPInput {
host: String!
port: Int!
baseDN: String!
bindDN: String!
bindPassword: String!
url: String!
adminUser: String!
adminPass: String!
}
input ACLInput {
path: String!
subjectType: String!
subjectId: Int!
canSearch: Boolean!
canView: Boolean!
canRead: Boolean!
canEdit: Boolean!
canCreate: Boolean!
canDelete: Boolean!
canMove: Boolean!
}
input SaveDocumentInput {

View File

@@ -232,6 +232,21 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "moveDocument"):
s.handleMoveDocument(w, req, sess)
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "importLdapSubject"):
s.handleImportLdapSubject(w, req, sess)
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
s.handleAcl(w, req, sess)
case strings.Contains(q, "setAcl"):
s.handleSetAcl(w, req, sess)
case strings.Contains(q, "removeAcl"):
s.handleRemoveAcl(w, req, sess)
case strings.Contains(q, "folders"):
s.handleFolders(w, sess)
@@ -813,16 +828,10 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
newCfg := *cfg // copy
if hasInput && input != nil {
port := 389
if p, ok := input["port"].(float64); ok {
port = int(p)
}
newCfg.LDAP = config.LDAPConfig{
Host: strVal(input, "host"),
Port: port,
BaseDN: strVal(input, "baseDN"),
BindDN: strVal(input, "bindDN"),
BindPassword: strVal(input, "bindPassword"),
Url: strVal(input, "url"),
AdminUser: strVal(input, "adminUser"),
AdminPass: strVal(input, "adminPass"),
}
} else {
// Disable LDAP
@@ -863,12 +872,10 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
}
var ldap map[string]interface{}
if cfg.LDAP.Host != "" {
if cfg.LDAP.Url != "" {
ldap = map[string]interface{}{
"host": cfg.LDAP.Host,
"port": cfg.LDAP.Port,
"baseDN": cfg.LDAP.BaseDN,
"bindDN": cfg.LDAP.BindDN,
"url": cfg.LDAP.Url,
"adminUser": cfg.LDAP.AdminUser,
}
}
@@ -1005,16 +1012,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
}
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
port := 389
if p, ok := ldapRaw["port"].(float64); ok {
port = int(p)
}
cfg.LDAP = config.LDAPConfig{
Host: strVal(ldapRaw, "host"),
Port: port,
BaseDN: strVal(ldapRaw, "baseDN"),
BindDN: strVal(ldapRaw, "bindDN"),
BindPassword: strVal(ldapRaw, "bindPassword"),
Url: strVal(ldapRaw, "url"),
AdminUser: strVal(ldapRaw, "adminUser"),
AdminPass: strVal(ldapRaw, "adminPass"),
}
}
@@ -1081,34 +1082,24 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
// ── LDAP test handler ─────────────────────────────────────────────────────────
func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) {
input, _ := req.Variables["i"].(map[string]interface{})
if input == nil {
writeGQLError(w, "missing LDAP input")
return
}
url_ := strVal(req.Variables, "url")
adminUser := strVal(req.Variables, "adminUser")
adminPass := strVal(req.Variables, "adminPass")
host := strVal(input, "host")
port := 389
if p, ok := input["port"].(float64); ok {
port = int(p)
}
bindDN := strVal(input, "bindDN")
bindPassword := strVal(input, "bindPassword")
if host == "" {
if url_ == "" {
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{
"success": false, "message": "host is required",
"success": false, "message": "url is required",
},
},
})
return
}
err := auth.TestLDAP(host, port, bindDN, bindPassword)
err := auth.TestLDAP(url_, adminUser, adminPass)
if err != nil {
log.Printf("[ldap] test failed (%s:%d): %v", host, port, err)
log.Printf("[ldap] test failed (%s): %v", url_, err)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{
@@ -1119,7 +1110,7 @@ func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest)
return
}
log.Printf("[ldap] test succeeded (%s:%d)", host, port)
log.Printf("[ldap] test succeeded (%s)", url_)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{
@@ -1246,10 +1237,203 @@ func firstWord(s string) string {
}
func strVal(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, _ := m[key].(string)
return v
}
// ── LDAP & ACL Additions ──────────────────────────────────────────────────────
func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
url_ := strVal(req.Variables, "url")
adminUser := strVal(req.Variables, "adminUser")
adminPass := strVal(req.Variables, "adminPass")
if url_ == "" {
s.mu.RLock()
if s.cfg != nil && s.cfg.LDAP.Url != "" {
url_ = s.cfg.LDAP.Url
adminUser = s.cfg.LDAP.AdminUser
adminPass = s.cfg.LDAP.AdminPass
}
s.mu.RUnlock()
}
users, groups, err := auth.BrowseLDAP(url_, adminUser, adminPass)
if err != nil {
writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err))
return
}
if users == nil {
users = []string{}
}
if groups == nil {
groups = []string{}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"ldapBrowse": map[string]interface{}{
"users": users,
"groups": groups,
},
},
})
}
func (s *Server) handleImportLdapSubject(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
typ := strVal(req.Variables, "type")
name := strVal(req.Variables, "name")
s.mu.RLock()
database := s.database
s.mu.RUnlock()
var err error
if typ == "user" {
err = database.CreateOrUpdateLDAPUser(name)
} else if typ == "group" {
err = database.CreateOrUpdateGroup(name, true)
} else {
writeGQLError(w, "Invalid subject type")
return
}
if err != nil {
writeGQLError(w, fmt.Sprintf("DB Error: %v", err))
return
}
writeJSON(w, `{"data":{"importLdapSubject":true}}`)
}
func (s *Server) handleSetAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
input, _ := req.Variables["input"].(map[string]interface{})
if input == nil {
writeGQLError(w, "Missing input")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
var entry db.ACLEntry
entry.Path = strVal(input, "path")
entry.SubjectType = strVal(input, "subjectType")
if idF, ok := input["subjectId"].(float64); ok {
entry.SubjectID = int64(idF)
} else {
writeGQLError(w, "Missing subjectId")
return
}
entry.CanSearch, _ = input["canSearch"].(bool)
entry.CanView, _ = input["canView"].(bool)
entry.CanRead, _ = input["canRead"].(bool)
entry.CanEdit, _ = input["canEdit"].(bool)
entry.CanCreate, _ = input["canCreate"].(bool)
entry.CanDelete, _ = input["canDelete"].(bool)
entry.CanMove, _ = input["canMove"].(bool)
if err := database.SetACL(entry); err != nil {
writeGQLError(w, err.Error())
return
}
writeJSON(w, `{"data":{"setAcl":true}}`)
}
func (s *Server) handleRemoveAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
idF, ok := req.Variables["id"].(float64)
if !ok {
writeGQLError(w, "Missing id")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if err := database.RemoveACL(int64(idF)); err != nil {
writeGQLError(w, err.Error())
return
}
writeJSON(w, `{"data":{"removeAcl":true}}`)
}
func (s *Server) handleAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
path := strVal(req.Variables, "path")
if path == "" {
writeGQLError(w, "Missing path")
return
}
// Example: Allow everyone to see ACLs for now, or restrict to admin
s.mu.RLock()
database := s.database
s.mu.RUnlock()
acls, err := database.GetACLsForPath(path)
if err != nil {
writeGQLError(w, err.Error())
return
}
var aclData []map[string]interface{}
for _, a := range acls {
aclData = append(aclData, map[string]interface{}{
"id": a.ID,
"path": a.Path,
"subjectType": a.SubjectType,
"subjectId": a.SubjectID,
"canSearch": a.CanSearch,
"canView": a.CanView,
"canRead": a.CanRead,
"canEdit": a.CanEdit,
"canCreate": a.CanCreate,
"canDelete": a.CanDelete,
"canMove": a.CanMove,
})
}
if aclData == nil {
aclData = []map[string]interface{}{}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"acl": aclData,
},
})
}
func dirOf(path string) string {
idx := strings.LastIndexAny(path, "/\\")
if idx < 0 {
@@ -1564,64 +1748,62 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
}
func (s *Server) handleImages(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
slug, _ := req.Variables["slug"].(string)
if slug == "" {
writeGQLError(w, "slug is required")
return
}
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
slug, _ := req.Variables["slug"].(string)
if slug == "" {
writeGQLError(w, "slug is required")
return
}
s.mu.RLock()
store := s.store
s.mu.RUnlock()
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
images, err := store.ListImages(slug)
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list images: %v", err))
return
}
images, err := store.ListImages(slug)
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list images: %v", err))
return
}
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}})
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}})
}
func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
slug, _ := req.Variables["slug"].(string)
filename, _ := req.Variables["filename"].(string)
if slug == "" || filename == "" {
writeGQLError(w, "slug and filename are required")
return
}
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
if err := store.DeleteImage(slug, filename); err != nil {
writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err))
return
}
log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username)
writeJSON(w, `{"data":{"deleteImage":true}}`)
}
slug, _ := req.Variables["slug"].(string)
filename, _ := req.Variables["filename"].(string)
if slug == "" || filename == "" {
writeGQLError(w, "slug and filename are required")
return
}
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
if err := store.DeleteImage(slug, filename); err != nil {
writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err))
return
}
log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username)
writeJSON(w, `{"data":{"deleteImage":true}}`)
}