Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 97 additions & 2 deletions src/main/java/com/pointbluetech/arborj/service/SchemaService.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public class SchemaService {
private Set<String> containerClasses;
private Map<String, LDAPAttributeInfo> attributeMap;
private Map<String, LDAPObjectClassInfo> objectClassMap;
/** lDAPDisplayName (lower) → rDNAttID (lower) from AD classSchema. Empty when not AD. */
private Map<String, String> adRdnAttIds = Map.of();

public SchemaService(LDAPService ldapService) {
this.ldapService = ldapService;
Expand Down Expand Up @@ -103,9 +105,11 @@ public void loadSchema() throws LDAPException {
}
// Fetch subschema ONCE and reuse for all loading
var schema = fetchSubschema();
adRdnAttIds = Map.of();
containerClasses = loadContainerClasses(schema);
attributeMap = loadAttributeMap(schema);
objectClassMap = loadObjectClassMap(schema);
applyRDNAttIDs(objectClassMap, adRdnAttIds);
}

public Set<String> getContainerClasses() {
Expand Down Expand Up @@ -168,11 +172,13 @@ private void loadADContainerClasses(Set<String> result) throws LDAPException {
String schemaDN = firstValue(rootDSE, "schemaNamingContext");
if (schemaDN == null) return;

// Search for all classSchema objects with possSuperiors
// Search for all classSchema objects with possSuperiors and rDNAttID
List<LDAPEntry> classSchemas = ldapService.search(schemaDN,
com.pointbluetech.arborj.model.SearchScope.SUBTREE,
"(objectClass=classSchema)",
"lDAPDisplayName", "possSuperiors", "systemPossSuperiors");
"lDAPDisplayName", "possSuperiors", "systemPossSuperiors", "rDNAttID");

adRdnAttIds = collectRDNAttIDs(classSchemas);

// Any class that appears as a possSuperior is a container
for (LDAPEntry entry : classSchemas) {
Expand Down Expand Up @@ -406,6 +412,95 @@ private List<String> resolveNaming(String className, Map<String, List<String>> n
return null;
}

/**
* Reads AD classSchema rDNAttID values. Keys and values are lowercased.
* OID-looking values are skipped so the combo is not filled with
* {@code 2.5.4.3} when the server does not return the LDAP display name.
*/
static Map<String, String> collectRDNAttIDs(List<LDAPEntry> classSchemas) {
Map<String, String> result = new LinkedHashMap<>();
if (classSchemas == null) return result;
for (LDAPEntry entry : classSchemas) {
if (entry == null) continue;
String name = entry.getFirstValue("lDAPDisplayName");
String rdn = entry.getFirstValue("rDNAttID");
if (name == null || name.isBlank() || rdn == null || rdn.isBlank()) continue;
String trimmed = rdn.trim();
if (looksLikeOid(trimmed)) continue;
result.put(name.trim().toLowerCase(Locale.ROOT), trimmed.toLowerCase(Locale.ROOT));
}
return result;
}

/**
* Sets each class's naming attribute from AD rDNAttID (direct or inherited
* via SUP), putting it first alongside any existing X-NDS_NAMING list.
*/
static void applyRDNAttIDs(Map<String, LDAPObjectClassInfo> objectClassMap,
Map<String, String> rdnAttIds) {
if (objectClassMap == null || objectClassMap.isEmpty()
|| rdnAttIds == null || rdnAttIds.isEmpty()) {
return;
}
for (String className : new ArrayList<>(objectClassMap.keySet())) {
LDAPObjectClassInfo info = objectClassMap.get(className);
if (info == null) continue;
String rdn = resolveRDNAttID(className, objectClassMap, rdnAttIds, new HashSet<>());
if (rdn == null || rdn.isBlank()) continue;
List<String> naming = mergeNamingAttribute(info.getNamingAttributes(), rdn);
if (Objects.equals(info.getNamingAttributes(), naming)) continue;
objectClassMap.put(className, new LDAPObjectClassInfo(
info.getName(),
info.getMustAttributes(),
info.getMayAttributes(),
info.getSuperClass(),
naming
));
}
}

private static String resolveRDNAttID(String className,
Map<String, LDAPObjectClassInfo> objectClassMap,
Map<String, String> rdnAttIds,
Set<String> visited) {
String current = className;
while (current != null) {
String key = current.toLowerCase(Locale.ROOT);
if (!visited.add(key)) break;
String rdn = rdnAttIds.get(key);
if (rdn != null && !rdn.isBlank()) return rdn;
LDAPObjectClassInfo info = objectClassMap.get(key);
current = info != null ? info.getSuperClass() : null;
}
return null;
}

private static List<String> mergeNamingAttribute(List<String> existing, String rdnAttId) {
Map<String, String> merged = new LinkedHashMap<>();
merged.put(rdnAttId.toLowerCase(Locale.ROOT), rdnAttId);
if (existing != null) {
for (String name : existing) {
if (name == null || name.isBlank()) continue;
merged.putIfAbsent(name.toLowerCase(Locale.ROOT), name);
}
}
return new ArrayList<>(merged.values());
}

/** True for values like {@code 2.5.4.3} (digits and dots only, with at least one of each). */
static boolean looksLikeOid(String value) {
if (value == null || value.isEmpty()) return false;
boolean hasDigit = false;
boolean hasDot = false;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c >= '0' && c <= '9') hasDigit = true;
else if (c == '.') hasDot = true;
else return false;
}
return hasDigit && hasDot;
}

private static String firstValue(Map<String, List<String>> attrs, String key) {
List<String> values = attrs.get(key);
if (values != null && !values.isEmpty()) return values.getFirst();
Expand Down
167 changes: 140 additions & 27 deletions src/main/java/com/pointbluetech/arborj/view/NewEntryDialog.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.pointbluetech.arborj.controller.MainController;
import com.pointbluetech.arborj.model.LDAPObjectClassInfo;
import com.unboundid.ldap.sdk.RDN;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
Expand All @@ -12,6 +13,7 @@
import javafx.scene.text.Font;

import java.util.*;
import java.util.regex.Pattern;

/**
* Two-step wizard dialog for creating a new LDAP entry.
Expand Down Expand Up @@ -51,6 +53,11 @@ public class NewEntryDialog extends Dialog<Void> {

private int currentStep = 1;

/** RFC 4512 descr: ALPHA *( ALPHA / DIGIT / HYPHEN ). */
private static final Pattern ATTR_DESCR = Pattern.compile("[A-Za-z][A-Za-z0-9-]*");
/** RFC 4512 numericoid: number *( DOT number ). */
private static final Pattern ATTR_OID = Pattern.compile("[0-9]+(?:\\.[0-9]+)*");

public NewEntryDialog(MainController controller, String parentDN) {
this(controller, parentDN, null);
}
Expand Down Expand Up @@ -228,10 +235,9 @@ private void populateAttributeFields() {
mustAttrsByLower.remove("objectclass");
Collection<String> mustAttrs = mustAttrsByLower.values();

// Parse RDN attribute name and value
String rdnAttr = rdnAttrCombo.getValue();
String rdnValue = rdnField.getText().trim();
if (rdnAttr == null || rdnAttr.isBlank()) rdnAttr = "cn";
// Prefill the RDN attribute from the (unescaped) RDN field value
String rdnAttr = resolvedRdnAttr();
String rdnValue = rdnField.getText() == null ? "" : rdnField.getText();

Font mono = Font.font("monospaced", 12);
int row = 0;
Expand All @@ -243,9 +249,11 @@ private void populateAttributeFields() {
TextField field = new TextField();
field.setFont(mono);

// Pre-populate the RDN attribute
// Prefill the RDN attribute from Step 1 and keep it read-only so
// the Add cannot disagree with the DN (Arbor does the same).
if (attr.equalsIgnoreCase(rdnAttr)) {
field.setText(rdnValue != null ? rdnValue : "");
field.setEditable(false);
}

attributeGrid.add(label, 0, row);
Expand Down Expand Up @@ -303,42 +311,73 @@ private void updateNamingAttributes(ComboBox<String> combo) {
Map<String, LDAPObjectClassInfo> ocMap = controller.getSchemaService().getObjectClassMap();
if (ocMap == null) return;

Set<String> namingAttrs = new LinkedHashSet<>();
List<LDAPObjectClassInfo> selected = new ArrayList<>();
for (String ocName : selectedClasses) {
LDAPObjectClassInfo info = ocMap.get(ocName);
if (info != null && info.getNamingAttributes() != null) {
namingAttrs.addAll(info.getNamingAttributes());
}
}

// Fallback: common naming attributes
if (namingAttrs.isEmpty()) {
namingAttrs.addAll(List.of("cn", "ou", "o", "c", "dc", "uid"));
selected.add(ocMap.get(ocName));
}
List<String> namingAttrs = namingAttributeChoices(selected);

String current = combo.getValue();
combo.getItems().setAll(namingAttrs);
if (current != null && namingAttrs.contains(current)) {
combo.setValue(current);
} else if (!namingAttrs.isEmpty()) {
combo.setValue(namingAttrs.iterator().next());
combo.setValue(namingAttrs.getFirst());
}
}

private String getRDN() {
String attr = rdnAttrCombo.getValue();
String value = rdnField.getText().trim();
if (attr == null || attr.isBlank()) attr = "cn";
return attr + "=" + value;
return formatRDN(resolvedRdnAttr(), rdnField.getText());
}

/**
* Editor text is the source of truth for the editable RDN combo:
* {@link ComboBox#getValue()} can lag typed text until Enter or focus loss.
*/
private String resolvedRdnAttr() {
String editorText = null;
if (rdnAttrCombo != null && rdnAttrCombo.isEditable()) {
TextField editor = rdnAttrCombo.getEditor();
if (editor != null) editorText = editor.getText();
}
String value = rdnAttrCombo == null ? null : rdnAttrCombo.getValue();
String resolved = resolveComboText(editorText, value);
return resolved.isEmpty() ? "cn" : resolved;
}

/**
* Prefers trimmed editor text over a stale committed combo value.
* Returns empty when both are blank (callers default to {@code cn}).
*/
static String resolveComboText(String editorText, String value) {
if (editorText != null) {
String trimmed = editorText.trim();
if (!trimmed.isEmpty()) return trimmed;
}
if (value != null) {
String trimmed = value.trim();
if (!trimmed.isEmpty()) return trimmed;
}
return "";
}

private boolean validateStep1() {
String value = rdnField.getText().trim();
String value = rdnField.getText() == null ? "" : rdnField.getText().trim();
if (value.isEmpty()) {
showAlert(Alert.AlertType.WARNING, "Validation",
"Please enter an RDN value.");
return false;
}
rdnField.setText(value);

String attr = resolvedRdnAttr();
if (!isValidAttributeType(attr)) {
showAlert(Alert.AlertType.WARNING, "Validation",
"RDN attribute must be an LDAP name such as cn, or a numeric OID such as 2.5.4.3.");
return false;
}
rdnAttrCombo.setValue(attr);

if (selectedClasses.isEmpty()) {
showAlert(Alert.AlertType.WARNING, "Validation",
"Please select at least one objectClass.");
Expand All @@ -350,6 +389,8 @@ private boolean validateStep1() {
// ---- Create entry ----

private void doCreate() {
String rdnAttr = resolvedRdnAttr();
String rdnValue = rdnField.getText() == null ? "" : rdnField.getText();
String rdn = getRDN();

// Validate required fields are filled
Expand All @@ -363,21 +404,93 @@ private void doCreate() {
}
}

// Build attribute map
Map<String, List<String>> attributes = new LinkedHashMap<>();
Map<String, String> fieldValues = new LinkedHashMap<>();
for (var entry : attributeFields.entrySet()) {
String value = entry.getValue().getText().trim();
if (!value.isEmpty()) {
attributes.put(entry.getKey(), List.of(value));
}
fieldValues.put(entry.getKey(), entry.getValue().getText());
}

Map<String, List<String>> attributes = buildCreateAttributes(fieldValues, rdnAttr, rdnValue);

List<String> objectClasses = new ArrayList<>(selectedClasses);

controller.createEntry(parentDN, rdn, objectClasses, attributes);
close();
}

/**
* Builds {@code attributeType=} plus an RFC 4514-escaped value. The RDN
* text field is the unescaped attribute value; {@code +} is escaped as
* part of that value (multi-valued RDNs are not supported).
*/
static String formatRDN(String attr, String unescapedValue) {
if (attr == null || attr.isBlank()) attr = "cn";
else attr = attr.trim();
String value = unescapedValue == null ? "" : unescapedValue.trim();
return new RDN(attr, value).toString();
}

/**
* Builds the LDAP Add attribute map from MUST field values plus the RDN.
* The RDN attribute is always included even when it is MAY (not a MUST
* field) — Active Directory requires the naming attribute on Add.
* The Add value is the unescaped RDN text (not the DN-escaped form).
* If the RDN attribute is already a MUST field, the RDN value wins so
* the Add cannot disagree with the DN.
*/
static Map<String, List<String>> buildCreateAttributes(Map<String, String> fieldValues,
String rdnAttr, String rdnValue) {
String attr = (rdnAttr == null || rdnAttr.isBlank()) ? "cn" : rdnAttr.trim();
String value = rdnValue == null ? "" : rdnValue.trim();

Map<String, List<String>> attributes = new LinkedHashMap<>();
if (fieldValues != null) {
for (var entry : fieldValues.entrySet()) {
if (entry.getKey() == null || entry.getKey().isBlank()) continue;
if (entry.getKey().equalsIgnoreCase(attr)) continue;
String fieldValue = entry.getValue() == null ? "" : entry.getValue().trim();
if (!fieldValue.isEmpty()) {
attributes.put(entry.getKey(), List.of(fieldValue));
}
}
}

if (!value.isBlank()) {
attributes.put(attr, List.of(value));
}
return attributes;
}

/**
* True for an RFC 4512 attribute descr ({@code cn}, {@code telephoneNumber})
* or a numeric OID ({@code 2.5.4.3}).
*/
static boolean isValidAttributeType(String attr) {
if (attr == null || attr.isBlank()) return false;
String trimmed = attr.trim();
return ATTR_DESCR.matcher(trimmed).matches() || ATTR_OID.matcher(trimmed).matches();
}

/**
* RDN combo choices for the selected objectClasses: schema naming
* attributes (X-NDS_NAMING / AD rDNAttID) in encounter order — so the
* class's naming attribute is first — then the conventional cn/ou/o/c/dc/uid
* fallback when the schema has none.
*/
static List<String> namingAttributeChoices(Iterable<LDAPObjectClassInfo> selectedClassInfos) {
Set<String> namingAttrs = new LinkedHashSet<>();
if (selectedClassInfos != null) {
for (LDAPObjectClassInfo info : selectedClassInfos) {
if (info != null && info.getNamingAttributes() != null) {
namingAttrs.addAll(info.getNamingAttributes());
}
}
}
if (namingAttrs.isEmpty()) {
namingAttrs.addAll(List.of("cn", "ou", "o", "c", "dc", "uid"));
}
return new ArrayList<>(namingAttrs);
}

private void showAlert(Alert.AlertType type, String title, String message) {
Alert alert = new Alert(type);
alert.setTitle(title);
Expand Down
Loading
Loading