Skip to content

Commit

Permalink
Improve LDAP schema support
Browse files Browse the repository at this point in the history
dnknth committed Aug 20, 2023
1 parent b28f97d commit f70c73e
Showing 13 changed files with 9,003 additions and 150 deletions.
6 changes: 4 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
@@ -520,16 +520,18 @@ def _oc(obj) -> dict:
}

def _at(obj) -> dict:
'Additional information about an object class'
'Additional information about an attribute'
r = _el(obj)
r.update({
'single_value' : bool(obj.single_value),
'no_user_mod' : bool(obj.no_user_mod),
'usage' : SCHEMA_ATTR_USAGE[obj.usage],

# FIXME avoid null values below
'equality' : obj.equality,
'syntax' : obj.syntax,
'substr' : obj.substr,
'ordering' : obj.ordering,
'usage' : SCHEMA_ATTR_USAGE[obj.usage],
})
return r

503 changes: 502 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite",
"lint": "eslint src",
"test": "vitest",
"build": "vite build",
"preview": "vite preview",
"tw-config": "tailwind-config-viewer -o"
@@ -23,7 +24,8 @@
"tailwind-config-viewer": "^1.7.2",
"tailwindcss": "^3.3",
"vite": "^4",
"vite-plugin-compression": "^0.5.0"
"vite-plugin-compression": "^0.5.0",
"vitest": "^0.34.2"
},
"eslintConfig": {
"root": true,
2 changes: 1 addition & 1 deletion src/components/NavBar.vue
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@
<span class="cursor-pointer" @click="$emit('show-modal', 'ldif-import')">Import…</span>

<dropdown-menu title="Schema">
<li role="menuitem" v-for="obj in app.schema.objectClasses._objects"
<li role="menuitem" v-for="obj in app.schema.ObjectClass.values"
:key="obj.name" @click="app.oc = obj.name;">
{{ obj.name }}
</li>
41 changes: 28 additions & 13 deletions src/components/TreeView.vue
Original file line number Diff line number Diff line change
@@ -49,6 +49,17 @@
get loaded() {
return !this.hasSubordinates || this.subordinates.length > 0;
},
parentDns: function(baseDn) {
const dns = [];
for (let dn = this.dn;;) {
dns.push(dn);
const idx = dn.indexOf(',');
if (idx == -1 || dn == baseDn) break;
dn = dn.subString(idx + 1);
}
return dns;
},
visible: function() {
if (!this.hasSubordinates || !this.open) return [this];
@@ -90,25 +101,30 @@
return;
}
// Reveal the selected DN in the tree
// by opening all parent nodes
const dn = new this.app.schema.DN(selected || this.tree.dn),
parents = dn.parents(this.tree.dn);
// Get all parents of the selected entry in the tree
const dn = new this.app.schema.DN(selected || this.tree.dn);
let hierarchy = [];
for (let node = dn; node; node = node.parent) {
hierarchy.push(node);
if (node == this.tree.dn) break;
}
parents.reverse();
for (let i=0; i < parents.length; ++i) {
const p = parents[i].value, node = this.tree.find(p);
// Reveal the selected entry by opening all parents
hierarchy.reverse();
for (let i = 0; i < hierarchy.length; ++i) {
const p = hierarchy[i].toString(),
node = this.tree.find(p);
if (!node) break;
if (!node.loaded) await this.reload(p);
node.open = true;
}
// Special case: Item was added, renamed or deleted
if (!this.tree.find(dn.value)) {
await this.reload(dn.parent.value);
this.tree.find(dn.parent.value).open = true;
// Reload parent if entry was added, renamed or deleted
if (!this.tree.find(dn.toString())) {
await this.reload(dn.parent.toString());
this.tree.find(dn.parent.toString()).open = true;
}
},
},
methods: {
@@ -140,7 +156,6 @@
if (!item.open && !item.loaded) await this.reload(item.dn);
item.open = !item.open;
},
},
}
</script>
14 changes: 6 additions & 8 deletions src/components/editor/AttributeRow.vue
Original file line number Diff line number Diff line change
@@ -30,10 +30,9 @@
</span>
<input v-else :value="values[index]" :id="attr + '-' + index" :type="type"
class="w-[90%] glyph outline-none bg-back border-x-0 border-t-0 border-b border-solid border-front/20 focus:border-accent px-1"
:class="{ structural: isStructural(val), auto: defaultValue,
illegal: (illegal && !empty) || duplicate(index) }"
:class="{ structural: isStructural(val), auto: defaultValue, illegal: (illegal && !empty) || duplicate(index) }"
:placeholder="placeholder" :disabled="disabled"
:title="equality == 'generalizedTimeMatch' ? dateString(val) : ''"
:title="attr.equality == 'generalizedTimeMatch' ? dateString(val) : ''"
@input="update" @focusin="query = ''"
@keyup="search" @keyup.esc="query = ''" />

@@ -166,12 +165,12 @@
// Is the given value a structural object class?
isStructural: function(val) {
return this.attr.name == 'objectClass' && this.app.schema.structural.includes(val);
return this.attr.name == 'objectClass' && this.app.schema.oc(val).structural;
},
// Is the given value an auxillary object class?
isAux: function(val) {
return this.attr.name == 'objectClass' && !this.app.schema.structural.includes(val);
return this.attr.name == 'objectClass' && !this.app.schema.oc(val).structural;
},
duplicate: function(index) {
@@ -218,7 +217,6 @@
|| (!this.attr.no_user_mod && !this.binary);
},
equality: function() { return this.attr.getField('equality'); },
password: function() { return this.attr.name == 'userPassword'; },
binary: function() {
@@ -234,7 +232,7 @@
},
completable: function() {
return this.equality == 'distinguishedNameMatch';
return this.attr.equality == 'distinguishedNameMatch';
},
placeholder: function() {
@@ -249,7 +247,7 @@
// Guess the <input> type for an attribute
type: function() {
if (this.password) return 'password';
if (this.equality == 'integerMatch') return 'number';
if (this.attr.equality == 'integerMatch') return 'number';
return 'text';
},
4 changes: 2 additions & 2 deletions src/components/editor/EntryEditor.vue
Original file line number Diff line number Diff line change
@@ -319,7 +319,7 @@
let attrs = this.entry.attrs.objectClass
.filter(oc => oc && oc != 'top')
.map(oc => this.app.schema.oc(oc))
.flatMap(oc => oc ? oc.getAttributes(kind): [])
.flatMap(oc => oc ? oc.$collect(kind): [])
.filter(unique);
attrs.sort();
return attrs;
@@ -346,7 +346,7 @@
structural: function() {
const oc = this.entry.attrs.objectClass
.map(oc => this.app.schema.oc(oc))
.filter(oc => oc && oc.isStructural)[0];
.filter(oc => oc && oc.structural)[0];
return oc ? oc.name : '';
},
20 changes: 12 additions & 8 deletions src/components/editor/NewEntryDialog.vue
Original file line number Diff line number Diff line change
@@ -5,9 +5,9 @@

<label>Object class:
<select ref="oc" v-model="objectClass">
<option v-for="cls in app.schema.structural" :key="cls.name">
{{ cls }}
</option>
<template v-for="cls in app.schema.ObjectClass.values" :key="cls.name">
<option v-if="cls.structural">{{ cls }}</option>
</template>
</select>
</label>

@@ -64,6 +64,13 @@
this.$emit('update:modal');
const objectClasses = [this.objectClass];
for (let oc = this.oc.$super; oc; oc = oc.$super) {
if (!oc.structural && oc.kind != 'abstract') {
objectClasses.push(oc.name);
}
}
const entry = {
meta: {
dn: this.rdn + '=' + this.name + ',' + this.dn,
@@ -75,10 +82,7 @@
isNew: true,
},
attrs: {
objectClass: [ this.objectClass ].concat(
this.oc.superClasses
.filter(oc => !oc.isStructural && oc.kind != 'abstract')
.map(oc => oc.name)),
objectClass: objectClasses,
},
changed: [],
};
@@ -89,7 +93,7 @@
// Choice list of RDN attributes for a new entry
rdns: function() {
if (!this.objectClass) return [];
const ocs = this.oc.getAttributes('must');
const ocs = this.oc.$collect('must');
if (ocs.length == 1) this.rdn = ocs[0];
return ocs;
},
21 changes: 8 additions & 13 deletions src/components/schema/AttributeCard.vue
Original file line number Diff line number Diff line change
@@ -4,20 +4,15 @@
<div class="header">{{ attr.desc }}</div>

<ul class="list-disc mt-2">
<template v-for="(val, key) in attr">
<li :key="key" v-if="val && hiddenFields.indexOf(key) == -1">
{{ key }}: {{ val }}
</li>
</template>
<li v-if="attr.$super">Parent:
<span class="cursor-pointer"
@click="$emit('update:modelValue', attr.$super.name)">{{ attr.$super }}</span>
</li>
<li v-if="attr.equality">Equality: {{ attr.equality }}</li>
<li v-if="attr.ordering">Ordering: {{ attr.ordering }}</li>
<li v-if="attr.substr">Substring: {{ attr.substr }}</li>
<li>Syntax: {{ attr.$syntax }} <span v-if="attr.binary">(binary)</span></li>
</ul>

<div v-if="attr.sup.length > 0" class="mt-2"><i>Parents:</i>
<ul class="list-disc mt-2">
<li v-for="name in attr.sup" :key="name">
<span class="cursor-pointer" @click="$emit('update:modelValue', name)">{{ name }}</span>
</li>
</ul>
</div>
</card>
</template>

8 changes: 4 additions & 4 deletions src/components/schema/ObjectClassCard.vue
Original file line number Diff line number Diff line change
@@ -10,17 +10,17 @@
</ul>
</div>

<div v-if="oc.must.length" class="mt-2"><i>Required attributes:</i>
<div v-if="oc.$collect('must').length" class="mt-2"><i>Required attributes:</i>
<ul class="list-disc">
<li v-for="name in oc.must" :key="name">
<li v-for="name in oc.$collect('must')" :key="name">
<span class="cursor-pointer" @click="app.attr = name;">{{ name }}</span>
</li>
</ul>
</div>

<div v-if="oc.may.length" class="mt-2"><i>Optional attributes:</i>
<div v-if="oc.$collect('may').length" class="mt-2"><i>Optional attributes:</i>
<ul class="list-disc">
<li v-for="name in oc.may" :key="name">
<li v-for="name in oc.$collect('may')" :key="name">
<span class="cursor-pointer" @click="app.attr = name;">{{ name }}</span>
</li>
</ul>
210 changes: 113 additions & 97 deletions src/components/schema/schema.js
Original file line number Diff line number Diff line change
@@ -7,149 +7,165 @@ export function LdapSchema(json) {
}

function RDN(value) {
this.value = value;
this.length = value.length;
this.text = value;
const parts = value.split('=');
this.attr = parts[0];
this.name = parts[1];
this.attrName = parts[0].trim();
this.value = parts[1].trim();
}

RDN.prototype = {
schema: this,
toString: function() { return this.value; },
valueOf: function() { return this.value; },
equals: function(other) {
return this.schema.attr(this.attr).equals(this.name, other.name);
toString: function() { return this.text; },

eq: function(other) {
return other
&& this.attr.eq(other.attr)
&& this.attr.matcher(this.value, other.value);
},
};

get attr() {
return this.$attributes.$get(this.attrName);
},
};

function DN(value) {
this.value = value;
this.text = value;
const parts = value.split(',');
this.length = parts.length;
this.rdn = new RDN(parts[0]);
this.parent = parts.length == 1 ? undefined
: new DN(value.slice(parts[0].length + 1));
}

DN.prototype = {
toString: function() { return this.value; },
valueOf: function() { return this.value; },

get parent() {
return !this.value.includes(',') ? undefined
: new DN(this.value.slice(this.rdn.length + 1));
},

parents: function(base) {
const p = this.parent;
return p && p.value.includes(base || '')
? [p].concat(p.parents(base)) : [];
},
toString: function() { return this.text; },

parts: function() {
return this.value.split(',').map(rdn => new RDN(rdn));
},

equals: function(other) {
if (this.length != other.length) return false;
const parts = other.parts;
return this.parts.every((e, i) => e.equals(parts[i]));
eq: function(other) {
if (!other || !this.rdn.eq(other.rdn)) return false;
if (!this.parent && !other.parent) return true;
return this.parent && this.parent.eq(other.parent);
},
};


function ObjectClass(json) {
Object.assign(this, json);
}

ObjectClass.prototype = {

schema: this,

get superClasses() {
let result = [];
for (let oc = this; oc; oc = this.schema.oc(oc.sup[0])) result.push(oc);
return result;
},

get isStructural() { return this.kind == 'structural'; },

// collect values from a field, across all superclasses
getAttributes: function(name) {
const result = this.superClasses
.map(oc => oc[name])
.filter(attrs => attrs)
.flat()
.map(attr => this.schema.attr(attr).name)
get structural() { return this.kind == 'structural'; },

// gather values from a field across all superclasses
$collect: function(name) {
let attributes = [];
for (let oc = this; oc; oc = oc.$super) {
const attrs = oc[name];
if (attrs) attributes.push(attrs);
}

const result = attributes.flat()
.map(attr => this.$attributes.$get(attr).name)
.filter(unique);
result.sort();
return result;
},

toString: function() { return this.names[0]; },
};

get $super() {
const parent = Object.getPrototypeOf(this);
return parent.sup ? parent : undefined;
},
};

function Attribute(json) {
Object.assign(this, json);
Object.getOwnPropertyNames(json)
.forEach(prop => {
const value = json[prop];
if (value !== null) this[prop] = value;
});
}

Attribute.prototype = {
schema: this,
toString: function() { return this.names[0]; },

// look up a field across superclasses
getField: function(name) {
for (let attr = this; attr; attr = this.schema.attr(attr.sup[0])) {
const val = attr[name];
if (val) return val;
}
},

matchRules: {
"distinguishedNameMatch": (a, b) => new DN(a).equals(new DN(b)),
"caseIgnoreIA5Match": (a, b) => a.toLowerCase() == b.toLowerCase(),
"caseIgnoreMatch": (a, b) => a.toLowerCase() == b.toLowerCase(),
// "generalizedTimeMatch",
"integerMatch": (a, b) => +a == +b,
"numericStringMatch": (a, b) => +a == +b,
// See: https://ldap.com/matching-rules/
distinguishedNameMatch: (a, b) => new DN(a).eq(new DN(b)),
caseIgnoreIA5Match: (a, b) => a.toLowerCase() == b.toLowerCase(),
caseIgnoreMatch: (a, b) => a.toLowerCase() == b.toLowerCase(),
// generalizedTimeMatch: ...
integerMatch: (a, b) => +a == +b,
numericStringMatch: (a, b) => +a == +b,
octetStringMatch: (a, b) => a == b,
},

equals: function(a, b) {
const predicate = this.matchRules[this.getField('equality')]
|| ((a, b) => a == b);
return predicate(a, b);
get matcher() {
return this.matchRules[this.equality]
|| this.matchRules.octetStringMatch;
},

eq: function(other) { return other && this.oid == other.oid; },

get binary() {
if (this.equality == 'octetStringMatch') return undefined;
return this.$syntax.not_human_readable;
},

get $syntax() { return this.$syntaxes[this.syntax]; },

get $super() {
const parent = Object.getPrototypeOf(this);
return parent.sup ? parent : undefined;
},
};

function FlatMap(json, ctor) {
this._objects = [];
if (!json) return;
function Syntax(json) {
Object.assign(this, json);
}

this._objects = Object.getOwnPropertyNames(json)
.map(prop => ctor ? new ctor(json[prop]) : json[prop]);
Syntax.prototype = {
toString: function() { return this.desc; },
};

function PropertyMap(json, ctor, prop) {
Object.getOwnPropertyNames(json || {})
.map(prop => new ctor(json[prop]))
.forEach(obj => { this[obj[prop]] = obj; });
}

function FlatPropertyMap(json, ctor, prop) {
this.$values = Object.getOwnPropertyNames(json || {})
.map(key => new ctor(json[key]));

this._objects.forEach(obj => obj.names.forEach(
name => { this[name.toLowerCase()] = obj; }));
// Map objects to each available prop value
this.$values.forEach(obj => obj[prop].forEach(
key => { this[key.toLowerCase()] = obj; }));

// Model object inheritance as JS prototype chain
this.$values.forEach(obj => {
const key = obj.sup[0],
parent = key ? this[key.toLowerCase()] : undefined;
if (parent) Object.setPrototypeOf(obj, parent);
});

this.$get = function(name) {
return name ? this[name.toLowerCase()] : undefined;
};
}

// LdapSchema constructor
const syntaxes = new PropertyMap(json.syntaxes, Syntax, 'oid'),
attributes = new FlatPropertyMap(json.attributes, Attribute, 'names'),
objectClasses = new FlatPropertyMap(json.objectClasses, ObjectClass, 'names');

this.attributes = new FlatMap(json.attributes, Attribute);
this.objectClasses = new FlatMap(json.objectClasses, ObjectClass);
this.syntaxes = json.syntaxes || [];
this.DN = DN;
Attribute.prototype.$syntaxes = syntaxes;
RDN.prototype.$attributes = attributes;
ObjectClass.prototype.$attributes = attributes;
ObjectClass.values = objectClasses;

this.structural = this.objectClasses._objects
.filter(oc => oc.isStructural)
.map(oc => oc.name);
}
this.attr = (name) => attributes.$get(name);
this.oc = (name) => objectClasses.$get(name);

LdapSchema.prototype = {
attr: function(name) {
return name ? this.attributes[name.toLowerCase()] : undefined;
},

oc: function(name) {
return name ? this.objectClasses[name.toLowerCase()] : undefined;
}
};
this.DN = DN;
this.RDN = RDN;
this.Attribute = Attribute;
this.ObjectClass = ObjectClass;
}
78 changes: 78 additions & 0 deletions src/components/schema/schema.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'vitest';
import { LdapSchema } from './schema.js';
import jsonSchema from './test-schema.json';

const schema = new LdapSchema(jsonSchema);


describe('LDAP schema items', () => {
describe('DNs and RDNs', () => {
const
dn1 = new schema.DN('dc=foo,dc=bar'), rdn1 = dn1.rdn,
dn2 = new schema.DN('domainComponent=FOO,domainComponent=BAR'), rdn2 = dn2.rdn,
dn3 = new schema.DN('domainComponent=bar'), rdn3 = dn3.rdn;

test('Test RDN attribute equality', () =>
expect(rdn1.attr).toEqual(schema.attr('domainComponent')));

test('Test RDN equality', () =>
expect(rdn1.eq(rdn2)).toBeTruthy());

test('Test RDN inequality', () =>
expect(rdn1.eq(rdn3)).toBeFalsy());

test('Test RDN part of DN', () =>
expect(dn1.rdn.eq(rdn2)).toBeTruthy());

test('Test DN parent', () =>
expect(dn2.parent.eq(dn3)).toBeTruthy());

test('Test DN grandparent', () =>
expect(dn3.parent).toBeUndefined());

test('Test DN equality', () =>
expect(dn1.eq(dn2)).toBeTruthy());
});

describe('Attributes', () => {
const sn = schema.attr('sn'),
name = schema.attr('name');

test('SN is found in schema', () =>
expect(sn).toBeDefined());

test('SN has name as prototype', () =>
expect(Object.getPrototypeOf(sn)).toEqual(name));

test('SN is an Attribute', () =>
expect(sn).toBeInstanceOf(schema.Attribute));

test('SN has no own equality', () =>
expect(Object.getOwnPropertyNames(sn)).not.toContain('equality'));

test('SN inherits equality from name', () =>
expect(sn.equality).toBeDefined());

test('SN syntax resolution', () =>
expect(sn.$syntax.toString()).toEqual('Directory String'));
});

describe('ObjectClass inheritance', () => {
const top = schema.oc('top'),
dnsDomain = schema.oc('dnsDomain');

function superClasses(cls) {
let result = [];
for (let oc = cls; oc; oc = Object.getPrototypeOf(oc)) {
result.push(oc);
}
return result;
}

test('top is an ObjectClass', () =>
expect(top).toBeInstanceOf(schema.ObjectClass));

test('dnsDomain inherits from top', () =>
expect(superClasses(dnsDomain)).toContain(top));
});
});
8,242 changes: 8,242 additions & 0 deletions src/components/schema/test-schema.json

Large diffs are not rendered by default.

0 comments on commit f70c73e

Please sign in to comment.