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

@@ -73,16 +73,12 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
cfg := m.cfg cfg := m.cfg
m.mu.RUnlock() m.mu.RUnlock()
if cfg == nil || cfg.LDAP.Host == "" { if cfg == nil || cfg.LDAP.Url == "" {
return "", "", errors.New("invalid credentials")
}
if err := ldapBind(cfg.LDAP.Host, cfg.LDAP.Port, cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil {
return "", "", errors.New("invalid credentials") return "", "", errors.New("invalid credentials")
} }
if err := ldapUserBind(cfg, username, password); err != nil { if err := ldapUserBind(cfg, username, password); err != nil {
return "", "", errors.New("invalid credentials") return "", "", err
} }
tok, err := m.createSession(username, "user") tok, err := m.createSession(username, "user")
@@ -90,44 +86,104 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
} }
// TestLDAP tests an LDAP configuration by performing a service-account bind. // TestLDAP tests an LDAP configuration by performing a service-account bind.
func TestLDAP(host string, port int, bindDN, bindPassword string) error { func TestLDAP(url string, adminUser, adminPassword string) error {
return ldapBind(host, port, bindDN, bindPassword) l, err := ldap.DialURL(url)
}
func ldapBind(host string, port int, bindDN, bindPassword string) error {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil { if err != nil {
return err return err
} }
defer l.Close() defer l.Close()
return l.Bind(bindDN, bindPassword) if adminUser != "" {
return l.Bind(adminUser, adminPassword)
}
return nil
} }
func ldapUserBind(cfg *config.Config, username, password string) error { func ldapUserBind(cfg *config.Config, username, password string) error {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", cfg.LDAP.Host, cfg.LDAP.Port)) l, err := ldap.DialURL(cfg.LDAP.Url)
if err != nil { if err != nil {
return err return err
} }
defer l.Close() defer l.Close()
if err := l.Bind(cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil { if cfg.LDAP.AdminUser != "" {
if err := l.Bind(cfg.LDAP.AdminUser, cfg.LDAP.AdminPass); err != nil {
return err return err
} }
}
sr, err := l.Search(&ldap.SearchRequest{ searchRequest := ldap.NewSearchRequest(
BaseDN: cfg.LDAP.BaseDN, "", // Search from root or a specific BaseDN if needed. Assuming root here since URL can contain it, or we rely on LDAP configured properly
Filter: fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
Scope: ldap.ScopeWholeSubtree, fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)),
}) []string{"dn"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil { if err != nil {
return err return err
} }
if len(sr.Entries) != 1 { if len(sr.Entries) == 0 {
return errors.New("user not found in LDAP") return errors.New("user not found in LDAP")
} }
// Bind to the exact DN of the user discovered
return l.Bind(sr.Entries[0].DN, password) return l.Bind(sr.Entries[0].DN, password)
} }
// BrowseLDAP returns users and groups from the LDAP server.
func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string, error) {
l, err := ldap.DialURL(url)
if err != nil {
return nil, nil, err
}
defer l.Close()
if adminUser != "" {
if err := l.Bind(adminUser, adminPassword); err != nil {
return nil, nil, err
}
}
// Search for Users
userReq := ldap.NewSearchRequest(
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(|(objectClass=person)(objectClass=user))",
[]string{"uid", "sAMAccountName", "cn"},
nil,
)
var users []string
if sr, err := l.Search(userReq); err == nil {
for _, e := range sr.Entries {
if v := e.GetAttributeValue("sAMAccountName"); v != "" {
users = append(users, v)
} else if v := e.GetAttributeValue("uid"); v != "" {
users = append(users, v)
} else if v := e.GetAttributeValue("cn"); v != "" {
users = append(users, v)
}
}
}
// Search for Groups
groupReq := ldap.NewSearchRequest(
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(|(objectClass=groupOfNames)(objectClass=group))",
[]string{"cn"},
nil,
)
var groups []string
if sr, err := l.Search(groupReq); err == nil {
for _, e := range sr.Entries {
if v := e.GetAttributeValue("cn"); v != "" {
groups = append(groups, v)
}
}
}
return users, groups, nil
}
func (m *Manager) createSession(username, role string) (string, error) { func (m *Manager) createSession(username, role string) (string, error) {
token, err := generateToken() token, err := generateToken()
if err != nil { if err != nil {

View File

@@ -16,11 +16,9 @@ type Config struct {
} }
type LDAPConfig struct { type LDAPConfig struct {
Host string `json:"host"` Url string `json:"url"`
Port int `json:"port"` AdminUser string `json:"admin_user"`
BaseDN string `json:"base_dn"` AdminPass string `json:"admin_pass"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
} }
// ErrRequireSetup is returned when config is missing or empty, // ErrRequireSetup is returned when config is missing or empty,

View File

@@ -51,8 +51,36 @@ func (d *DB) init() error {
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL, pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
is_ldap BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) );
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
is_ldap BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY(user_id, group_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS acl (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
subject_type TEXT NOT NULL CHECK(subject_type IN ('user', 'group')),
subject_id INTEGER NOT NULL,
can_search BOOLEAN NOT NULL DEFAULT 0,
can_view BOOLEAN NOT NULL DEFAULT 0,
can_read BOOLEAN NOT NULL DEFAULT 0,
can_edit BOOLEAN NOT NULL DEFAULT 0,
can_create BOOLEAN NOT NULL DEFAULT 0,
can_delete BOOLEAN NOT NULL DEFAULT 0,
can_move BOOLEAN NOT NULL DEFAULT 0,
UNIQUE(path, subject_type, subject_id)
);
`) `)
return err return err
} }
@@ -153,3 +181,96 @@ func (d *DB) UserCount() (int, error) {
err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err return n, err
} }
// --- LDAP & Roles ---
// CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user').
func (d *DB) CreateOrUpdateLDAPUser(username string) error {
_, err := d.sql.Exec(`
INSERT INTO users (username, pass_hash, role, is_ldap)
VALUES (?, '', 'user', 1)
ON CONFLICT(username) DO UPDATE SET is_ldap=1;
`, username)
return err
}
// CreateOrUpdateGroup inserts or updates a group (local or LDAP).
func (d *DB) CreateOrUpdateGroup(name string, isLdap bool) error {
ldVal := 0
if isLdap {
ldVal = 1
}
_, err := d.sql.Exec(`
INSERT INTO groups (name, is_ldap)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET is_ldap=excluded.is_ldap;
`, name, ldVal)
return err
}
// --- ACL Methods ---
type ACLEntry struct {
ID int64
Path string
SubjectType string
SubjectID int64
CanSearch bool
CanView bool
CanRead bool
CanEdit bool
CanCreate bool
CanDelete bool
CanMove bool
}
// SetACL inserts or replaces an ACL entry.
func (d *DB) SetACL(entry ACLEntry) error {
_, err := d.sql.Exec(`
INSERT INTO acl (path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path, subject_type, subject_id) DO UPDATE SET
can_search = excluded.can_search,
can_view = excluded.can_view,
can_read = excluded.can_read,
can_edit = excluded.can_edit,
can_create = excluded.can_create,
can_delete = excluded.can_delete,
can_move = excluded.can_move;
`, entry.Path, entry.SubjectType, entry.SubjectID,
entry.CanSearch, entry.CanView, entry.CanRead, entry.CanEdit,
entry.CanCreate, entry.CanDelete, entry.CanMove)
return err
}
// RemoveACL deletes an ACL entry by ID.
func (d *DB) RemoveACL(id int64) error {
_, err := d.sql.Exec(`DELETE FROM acl WHERE id = ?`, id)
return err
}
// GetACLsForPath retrieves all ACL definitions for a specific document or folder.
func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
rows, err := d.sql.Query(`
SELECT id, path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move
FROM acl WHERE path = ?
`, path)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ACLEntry
for rows.Next() {
var e ACLEntry
if err := rows.Scan(
&e.ID, &e.Path, &e.SubjectType, &e.SubjectID,
&e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit,
&e.CanCreate, &e.CanDelete, &e.CanMove,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}

View File

@@ -31,14 +31,20 @@ type Query {
# List images next to a document. # List images next to a document.
images(slug: String!): [ImageFile!]! 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 { type Mutation {
# First-run setup. # First-run setup.
setup(input: SetupInput!): Boolean! setup(input: SetupInput!): Boolean!
# Test an LDAP configuration before saving. # Test an LDAP configuration.
testLdapConnection(input: LDAPInput!): LDAPTestResult! testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult!
# Authenticate and receive a bearer token. # Authenticate and receive a bearer token.
login(username: String!, password: String!): String! login(username: String!, password: String!): String!
@@ -55,6 +61,13 @@ type Mutation {
# Modify LDAP Configuration (admin only). # Modify LDAP Configuration (admin only).
updateLdapConfig(input: LDAPInput): Boolean! 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). # Modify Storage Path (admin only).
updateStoragePath(path: String!): Boolean! updateStoragePath(path: String!): Boolean!
@@ -87,10 +100,27 @@ type AppConfig {
} }
type LDAPConfig { type LDAPConfig {
host: String! url: String!
port: Int! adminUser: String!
baseDN: String! }
bindDN: 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 { type ServerDirectory {
@@ -148,11 +178,22 @@ input SetupInput {
} }
input LDAPInput { input LDAPInput {
host: String! url: String!
port: Int! adminUser: String!
baseDN: String! adminPass: String!
bindDN: String! }
bindPassword: 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 { input SaveDocumentInput {

View File

@@ -232,6 +232,21 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "moveDocument"): case strings.Contains(q, "moveDocument"):
s.handleMoveDocument(w, req, sess) 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"): case strings.Contains(q, "folders"):
s.handleFolders(w, sess) s.handleFolders(w, sess)
@@ -813,16 +828,10 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
newCfg := *cfg // copy newCfg := *cfg // copy
if hasInput && input != nil { if hasInput && input != nil {
port := 389
if p, ok := input["port"].(float64); ok {
port = int(p)
}
newCfg.LDAP = config.LDAPConfig{ newCfg.LDAP = config.LDAPConfig{
Host: strVal(input, "host"), Url: strVal(input, "url"),
Port: port, AdminUser: strVal(input, "adminUser"),
BaseDN: strVal(input, "baseDN"), AdminPass: strVal(input, "adminPass"),
BindDN: strVal(input, "bindDN"),
BindPassword: strVal(input, "bindPassword"),
} }
} else { } else {
// Disable LDAP // Disable LDAP
@@ -863,12 +872,10 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
} }
var ldap map[string]interface{} var ldap map[string]interface{}
if cfg.LDAP.Host != "" { if cfg.LDAP.Url != "" {
ldap = map[string]interface{}{ ldap = map[string]interface{}{
"host": cfg.LDAP.Host, "url": cfg.LDAP.Url,
"port": cfg.LDAP.Port, "adminUser": cfg.LDAP.AdminUser,
"baseDN": cfg.LDAP.BaseDN,
"bindDN": cfg.LDAP.BindDN,
} }
} }
@@ -1005,16 +1012,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
} }
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil { 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{ cfg.LDAP = config.LDAPConfig{
Host: strVal(ldapRaw, "host"), Url: strVal(ldapRaw, "url"),
Port: port, AdminUser: strVal(ldapRaw, "adminUser"),
BaseDN: strVal(ldapRaw, "baseDN"), AdminPass: strVal(ldapRaw, "adminPass"),
BindDN: strVal(ldapRaw, "bindDN"),
BindPassword: strVal(ldapRaw, "bindPassword"),
} }
} }
@@ -1081,34 +1082,24 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
// ── LDAP test handler ───────────────────────────────────────────────────────── // ── LDAP test handler ─────────────────────────────────────────────────────────
func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) { func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) {
input, _ := req.Variables["i"].(map[string]interface{}) url_ := strVal(req.Variables, "url")
if input == nil { adminUser := strVal(req.Variables, "adminUser")
writeGQLError(w, "missing LDAP input") adminPass := strVal(req.Variables, "adminPass")
return
}
host := strVal(input, "host") if url_ == "" {
port := 389
if p, ok := input["port"].(float64); ok {
port = int(p)
}
bindDN := strVal(input, "bindDN")
bindPassword := strVal(input, "bindPassword")
if host == "" {
writeJSONObj(w, map[string]interface{}{ writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{ "data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{ "testLdapConnection": map[string]interface{}{
"success": false, "message": "host is required", "success": false, "message": "url is required",
}, },
}, },
}) })
return return
} }
err := auth.TestLDAP(host, port, bindDN, bindPassword) err := auth.TestLDAP(url_, adminUser, adminPass)
if err != nil { 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{}{ writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{ "data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{ "testLdapConnection": map[string]interface{}{
@@ -1119,7 +1110,7 @@ func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest)
return return
} }
log.Printf("[ldap] test succeeded (%s:%d)", host, port) log.Printf("[ldap] test succeeded (%s)", url_)
writeJSONObj(w, map[string]interface{}{ writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{ "data": map[string]interface{}{
"testLdapConnection": 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 { func strVal(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, _ := m[key].(string) v, _ := m[key].(string)
return v 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 { func dirOf(path string) string {
idx := strings.LastIndexAny(path, "/\\") idx := strings.LastIndexAny(path, "/\\")
if idx < 0 { if idx < 0 {
@@ -1623,5 +1807,3 @@ return
log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username) log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username)
writeJSON(w, `{"data":{"deleteImage":true}}`) writeJSON(w, `{"data":{"deleteImage":true}}`)
} }

View File

@@ -5,11 +5,9 @@ import FolderPicker from '@/components/FolderPicker.vue'
const storagePath = ref('') const storagePath = ref('')
const ldapEnabled = ref(false) const ldapEnabled = ref(false)
const ldapHost = ref('') const ldapUrl = ref('')
const ldapPort = ref(389) const ldapAdminUser = ref('')
const ldapBaseDN = ref('') const ldapAdminPassword = ref('')
const ldapBindDN = ref('')
const ldapBindPassword = ref('')
const oldPass = ref('') const oldPass = ref('')
const newPass = ref('') const newPass = ref('')
@@ -22,17 +20,20 @@ const showGitPrompt = ref(false)
const gitCommitMessage = ref('Initial commit') const gitCommitMessage = ref('Initial commit')
const gitCommitting = ref(false) const gitCommitting = ref(false)
// LDAP Browser State
const ldapUsers = ref<string[]>([])
const ldapGroups = ref<string[]>([])
const ldapBrowsing = ref(false)
onMounted(async () => { onMounted(async () => {
try { try {
const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`) const data = await gql<{ config: any }>(`{ config { storagePath ldap { url adminUser } } }`)
storagePath.value = data.config.storagePath || '' storagePath.value = data.config.storagePath || ''
if (data.config.ldap) { if (data.config.ldap) {
ldapEnabled.value = true ldapEnabled.value = true
ldapHost.value = data.config.ldap.host ldapUrl.value = data.config.ldap.url
ldapPort.value = data.config.ldap.port ldapAdminUser.value = data.config.ldap.adminUser
ldapBaseDN.value = data.config.ldap.baseDN
ldapBindDN.value = data.config.ldap.bindDN
} }
} catch (err: any) { } catch (err: any) {
status.value = { msg: err.message, isError: true } status.value = { msg: err.message, isError: true }
@@ -83,11 +84,9 @@ async function saveLdap() {
let input = null let input = null
if (ldapEnabled.value) { if (ldapEnabled.value) {
input = { input = {
host: ldapHost.value, url: ldapUrl.value,
port: ldapPort.value, adminUser: ldapAdminUser.value,
baseDN: ldapBaseDN.value, adminPass: ldapAdminPassword.value
bindDN: ldapBindDN.value,
bindPassword: ldapBindPassword.value
} }
} }
@@ -98,6 +97,52 @@ async function saveLdap() {
} }
} }
async function testLdap() {
try {
const data = await gql<{ testLdapConnection: { success: boolean, message: string } }>(
`mutation TestLdap($url: String!, $adminUser: String, $adminPass: String) {
testLdapConnection(url: $url, adminUser: $adminUser, adminPass: $adminPass) { success message }
}`,
{ url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value }
)
if (data.testLdapConnection.success) {
showStatus('LDAP connection successful.', false)
} else {
showStatus('LDAP connection failed: ' + data.testLdapConnection.message, true)
}
} catch (err: any) {
showStatus(err.message, true)
}
}
async function browseLdap() {
try {
ldapBrowsing.value = true
const data = await gql<{ ldapBrowse: { users: string[], groups: string[] } }>(
`query LdapBrowse($url: String, $adminUser: String, $adminPass: String) {
ldapBrowse(url: $url, adminUser: $adminUser, adminPass: $adminPass) { users groups }
}`,
{ url: ldapUrl.value || undefined, adminUser: ldapAdminUser.value || undefined, adminPass: ldapAdminPassword.value || undefined }
)
ldapUsers.value = data.ldapBrowse.users
ldapGroups.value = data.ldapBrowse.groups
showStatus('LDAP structure loaded.')
} catch (err: any) {
showStatus(err.message, true)
} finally {
ldapBrowsing.value = false
}
}
async function importSubject(type: 'user' | 'group', name: string) {
try {
await gql(`mutation ImportSubj($type: String!, $name: String!) { importLdapSubject(type: $type, name: $name) }`, { type, name })
showStatus(`Imported ${type} ${name} successfully.`)
} catch (err: any) {
showStatus(err.message, true)
}
}
async function savePassword() { async function savePassword() {
if (newPass.value.length < 8) { if (newPass.value.length < 8) {
showStatus('New password must be at least 8 characters.', true) showStatus('New password must be at least 8 characters.', true)
@@ -217,33 +262,58 @@ async function savePassword() {
</div> </div>
<div v-if="ldapEnabled" class="grid gap-4 sm:grid-cols-2 mb-4"> <div v-if="ldapEnabled" class="grid gap-4 sm:grid-cols-2 mb-4">
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Host</label>
<input v-model="ldapHost" class="input" placeholder="ldap.example.com" />
</div>
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Port</label>
<input v-model.number="ldapPort" type="number" class="input" placeholder="389" />
</div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="label">Base DN <span class="text-slate-400 text-xs font-normal">(e.g. dc=example,dc=com)</span></label> <label class="label">LDAP URL</label>
<input v-model="ldapBaseDN" class="input" placeholder="dc=example,dc=com" /> <input v-model="ldapUrl" class="input" placeholder="ldap://ldap.example.com:389" />
</div> </div>
<div class="sm:col-span-2 md:col-span-1"> <div class="sm:col-span-2 md:col-span-1">
<label class="label">Bind DN (Service Account)</label> <label class="label">Admin User (Optional Service Account)</label>
<input v-model="ldapBindDN" class="input" placeholder="cn=reader,dc=example,dc=com" /> <input v-model="ldapAdminUser" class="input" placeholder="cn=reader,dc=example,dc=com" />
</div> </div>
<div class="sm:col-span-2 md:col-span-1"> <div class="sm:col-span-2 md:col-span-1">
<label class="label">Bind Password</label> <label class="label">Admin Password</label>
<input v-model="ldapBindPassword" type="password" class="input" /> <input v-model="ldapAdminPassword" type="password" class="input" />
</div> </div>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end gap-2 mb-6">
<button class="btn-secondary" v-if="ldapEnabled" @click="testLdap">Test Connection</button>
<button class="btn-primary" @click="saveLdap"> <button class="btn-primary" @click="saveLdap">
{{ ldapEnabled ? 'Save LDAP Config' : 'Save & Disable LDAP' }} {{ ldapEnabled ? 'Save LDAP Config' : 'Save & Disable LDAP' }}
</button> </button>
</div> </div>
<!-- LDAP Browser -->
<div v-if="ldapEnabled" class="border-t border-slate-200 dark:border-slate-700 pt-6 mt-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-md font-semibold">LDAP Browser</h3>
<button class="btn-secondary text-sm py-1 px-3" @click="browseLdap" :disabled="ldapBrowsing">
{{ ldapBrowsing ? 'Loading...' : 'Browse Users & Groups' }}
</button>
</div>
<div v-if="ldapUsers.length || ldapGroups.length" class="grid sm:grid-cols-2 gap-6">
<div class="card p-4">
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2 border-b pb-1">Users</h4>
<ul class="max-h-48 overflow-y-auto space-y-1">
<li v-for="u in ldapUsers" :key="u" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
<span class="truncate">{{ u }}</span>
<button class="text-accent-600 hover:text-accent-700 text-xs" @click="importSubject('user', u)">Import</button>
</li>
</ul>
</div>
<div class="card p-4">
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2 border-b pb-1">Groups</h4>
<ul class="max-h-48 overflow-y-auto space-y-1">
<li v-for="g in ldapGroups" :key="g" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
<span class="truncate">{{ g }}</span>
<button class="text-accent-600 hover:text-accent-700 text-xs" @click="importSubject('group', g)">Import</button>
</li>
</ul>
</div>
</div>
<p v-else-if="!ldapBrowsing" class="text-sm text-slate-500 italic">Click browse to load LDAP hierarchy.</p>
</div>
</section> </section>
</div> </div>