From a5b19f39cc1035ffb0a9c05bccb27d90233ef23e Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Thu, 16 Mar 2023 02:21:46 +0100 Subject: [PATCH] Add schema hanlding methods & classes --- src/Entry.php | 2 +- src/InvalidPropertyException.php | 5 + src/Ldap.php | 43 +++++- src/Schema.php | 194 +++++++++++++++++++++++++++ src/Schema/Attribute.php | 85 ++++++++++++ src/Schema/MatchingRule.php | 23 ++++ src/Schema/MatchingRuleUse.php | 23 ++++ src/Schema/ObjectClass.php | 77 +++++++++++ src/Schema/SchemaEntry.php | 221 +++++++++++++++++++++++++++++++ src/Schema/Syntax.php | 30 +++++ 10 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 src/InvalidPropertyException.php create mode 100644 src/Schema.php create mode 100644 src/Schema/Attribute.php create mode 100644 src/Schema/MatchingRule.php create mode 100644 src/Schema/MatchingRuleUse.php create mode 100644 src/Schema/ObjectClass.php create mode 100644 src/Schema/SchemaEntry.php create mode 100644 src/Schema/Syntax.php diff --git a/src/Entry.php b/src/Entry.php index 8c2cf69..ab91ab3 100644 --- a/src/Entry.php +++ b/src/Entry.php @@ -109,7 +109,7 @@ class Entry { * (optional, default: null or array() if $all_values is True) * @param bool $all_values If True, return all the attribute values, otherwise return only the * first one (optional, default: true) - * @return mixed + * @return ( $all_values is True ? array : mixed ) */ public function get_values($attr, $default=null, $all_values=true) { $attr = mb_strtolower($attr); diff --git a/src/InvalidPropertyException.php b/src/InvalidPropertyException.php new file mode 100644 index 0000000..e779e78 --- /dev/null +++ b/src/InvalidPropertyException.php @@ -0,0 +1,5 @@ + 'sub', // Raise LdapException on error 'raise_on_error' => true, - + // Use schema + 'use_schema' => false, ); /** @@ -89,6 +91,12 @@ class Ldap { */ protected $_link = false; + /** + * The Schema object + * @var Schema|false + */ + protected $schema = false; + /** * Scopes associated with their search function * @var array @@ -382,6 +390,13 @@ class Ldap { return self :: $config_aliases; case 'default_config': return self :: $default_config; + case 'schema': + if ($this->schema) + return $this->schema; + if (!$this->use_schema) + return false; + $this->schema = Schema :: load($this); + return $this->schema; } return $this -> error("Invalid property '$key' requested"); } @@ -437,7 +452,7 @@ class Ldap { * @param string|null $filter Filter of the search (optional, default: null == use config) * @param array|null $params Other optional parameters of the search (optional, default: null == use config) * @param bool|null $raise See error() (optional, default: null) - * @return array|false Array of Entry object with DN as key, or False in case of error + * @return array|false Array of Entry object with DN as key, or False in case of error * @throws LdapException */ public function search($base=null, $filter=null, $params=null, $raise=null) { @@ -493,9 +508,33 @@ class Ldap { // @phpstan-ignore-next-line $entries[$dn] = new Entry($dn, $attrs); } + // @phpstan-ignore-next-line return $entries; } + /** + * Get one entry from LDAP directory by DN + * + * This method make easier to retreive a specific entry by its DN. A search will be runned on the + * specified DN with a base scope and if one and only one entry is found, it will be returned. + * + * @param string $dn DN of the expected entry + * @param string|null $filter Filter of the search (optional, default: null == use config) + * @param array|null $params Other optional parameters of the search (optional, + * default: null == use config, see search()) + * @param bool|null $raise See error() (optional, default: null) + * @return Entry|false The requested Entry object, or False in case of error + * @throws LdapException + */ + public function get_entry($dn, $filter=null, $params=null, $raise=null) { + $params = is_array($params)?$params:array(); + $params['scope'] = 'base'; + $entries = $this -> search($dn, $filter, $params, $raise); + if (is_array($entries) && count($entries) == 1) + return array_pop($entries); + return false; + } + /** * Get parameter value * @param array|null $params Array of provided parameter values diff --git a/src/Schema.php b/src/Schema.php new file mode 100644 index 0000000..f5eefb4 --- /dev/null +++ b/src/Schema.php @@ -0,0 +1,194 @@ + + */ + protected static $attributes_to_entry_types = array( + 'attributeTypes' => 'Attribute', + 'matchingRules' => 'MatchingRule', + 'matchingRuleUse' => 'MatchingRuleUse', + 'objectClasses' => 'ObjectClass', + 'ldapSyntaxes' => 'Syntax', + ); + + /** + * Loaded schema entries ordered by type + * @var array> + */ + protected $entries = array(); + + /** + * Loaded schema entries mapped with their OID + * @var array + */ + protected $oids = array(); + + /** + * Constructor + * @param Ldap $ldap The LDAP connection + * @param Entry $entry The cn=SubSchema LDAP entry object + * @return void + */ + public function __construct(&$ldap, &$entry) { + $this -> ldap = $ldap; + $this -> entry = $entry; + $this -> parse(); + } + + /** + * Load schema from provided LDAP connection + * @param Ldap $ldap + * @param bool|null $raise + * @return Schema|false Schema object on success, false on error + */ + public static function load(&$ldap, $raise=null) { + $entry = $ldap->get_entry( + 'cn=SubSchema', '(objectclass=*)', + array('attributes' => array_keys(self :: $attributes_to_entry_types)) + ); + if (!$entry instanceof Entry) + return $ldap->error('Fail to load cn=SubSchema entry', $raise); + return new Schema($ldap, $entry); + } + + /** + * Parse SubSchema entry attributes values + * @return void + */ + protected function parse() { + foreach (self :: $attributes_to_entry_types as $attr => $type) { + $type_name = strtolower($type); + foreach($this -> entry -> get_values($attr, array(), true) as $value) { + // @phpstan-ignore-next-line + $entry = call_user_func("\\EesyLDAP\\Schema\\$type::parse", $value); + if (!$entry instanceof Schema\SchemaEntry) { + $this -> ldap -> error("Fail to parse %s schema value: %s", null, $attr, $value); + continue; + } + $oid = $entry->oid; + if ($type != 'MatchingRuleUse') { + if (array_key_exists($oid, $this->oids)) { + $this -> ldap -> error( + "Duplicate OID %s found in schema: %s / %s", null, $oid, $this->oids[$oid], $entry); + continue; + } + $this->oids[$oid] = $entry; + } + + if (!array_key_exists($type_name, $this -> entries)) + $this -> entries[$type_name] = array(); + $name = $entry->name; + if (array_key_exists($name, $this -> entries[$type_name])) { + $this -> ldap -> error( + "Duplicate %s schema entry %s found: %s / %s", + null, $type, $name, $this -> entries[$type_name][$name], $entry + ); + continue; + } + $this -> entries[$type_name][$name] = $entry; + } + } + } + + /** + * Get a schema entry by type and name/oid + * @param string $type + * @param string $name_or_oid + * @return Schema\SchemaEntry|false + */ + protected function _get_entry($type, $name_or_oid) { + if ( + array_key_exists($name_or_oid, $this -> oids) + && is_a($this -> oids[$name_or_oid], "\\EesyLDAP\\Schema\\$type") + ) + return $this -> oids[$name_or_oid]; + $type_name = strtolower($type); + if (!array_key_exists($type_name, $this -> entries)) + return false; + foreach($this -> entries[$type_name] as $attr) + if ($attr->is_me($name_or_oid)) + return $attr; + return false; + } + + /** + * Get an attribute by name or oid + * @param string $name_or_oid + * @return Schema\Attribute|false + */ + public function attribute($name_or_oid) { + // @phpstan-ignore-next-line + return $this -> _get_entry('Attribute', $name_or_oid); + } + + /** + * Get an objectclass by name or oid + * @param string $name_or_oid + * @return \EesyLDAP\Schema\ObjectClass|false + */ + public function objectclass($name_or_oid) { + // @phpstan-ignore-next-line + return $this -> _get_entry('ObjectClass', $name_or_oid); + } + + /** + * Get a matching rule by name or oid + * @param string $name_or_oid + * @return Schema\MatchingRule|false + */ + public function matching_rule($name_or_oid) { + // @phpstan-ignore-next-line + return $this -> _get_entry('MatchingRule', $name_or_oid); + } + + /** + * Get a matching rule use by name or oid + * @param string $name_or_oid + * @return Schema\MatchingRuleUse|false + */ + public function matching_rule_use($name_or_oid) { + // @phpstan-ignore-next-line + return $this -> _get_entry('MatchingRuleUse', $name_or_oid); + } + + /** + * Get a syntax by name or oid + * @param string $name_or_oid + * @return Schema\Syntax|false + */ + public function syntax($name_or_oid) { + // @phpstan-ignore-next-line + return $this -> _get_entry('Syntax', $name_or_oid); + } + +} diff --git a/src/Schema/Attribute.php b/src/Schema/Attribute.php new file mode 100644 index 0000000..f3a24b2 --- /dev/null +++ b/src/Schema/Attribute.php @@ -0,0 +1,85 @@ + + */ + protected static $default_properties = array( + 'oid' => null, + 'name' => null, + 'desc' => null, + 'obsolete' => false, + 'sup' => null, + 'equality' => null, + 'ordering' => null, + 'substr' => null, + 'syntax' => null, + 'max_length' => null, + 'single-value' => false, + 'collective' => false, + 'usage' => null, + 'no-user-modification' => false, + ); + + /** + * Properties name aliases + * @var array + */ + protected static $property_aliases = array( + 'description' => 'desc', + 'superior' => 'sup', + 'substring' => 'substr', + 'single' => 'single-value', + 'single_value' => 'single-value', + 'no_user_modification' => 'no-user-modification', + ); + + /** + * Computed properties name + * @var array + */ + protected static $computed_properties = array( + 'names', + 'multiple', + ); + + /** + * Magic method to get attribute schema entry key + * @param string $key + * @return mixed + * @throws \EesyLDAP\InvalidPropertyException + */ + public function __get($key) { + switch($key) { + case 'multiple': + return !$this->single; + } + return parent::__get($key); + } +} diff --git a/src/Schema/MatchingRule.php b/src/Schema/MatchingRule.php new file mode 100644 index 0000000..5624992 --- /dev/null +++ b/src/Schema/MatchingRule.php @@ -0,0 +1,23 @@ + + */ + protected static $default_properties = array( + 'oid' => null, + 'name' => null, + 'syntax' => null, + ); + +} diff --git a/src/Schema/MatchingRuleUse.php b/src/Schema/MatchingRuleUse.php new file mode 100644 index 0000000..da1a237 --- /dev/null +++ b/src/Schema/MatchingRuleUse.php @@ -0,0 +1,23 @@ + $applies + */ +class MatchingRuleUse extends SchemaEntry { + + /** + * Default properties value + * @var array + */ + protected static $default_properties = array( + 'oid' => null, + 'name' => null, + 'applies' => array(), + ); + +} diff --git a/src/Schema/ObjectClass.php b/src/Schema/ObjectClass.php new file mode 100644 index 0000000..12fa3ac --- /dev/null +++ b/src/Schema/ObjectClass.php @@ -0,0 +1,77 @@ + $must + * @property-read array $may + */ +class ObjectClass extends SchemaEntry { + + /** + * Default properties value + * @var array + */ + protected static $default_properties = array( + 'oid' => null, + 'name' => null, + 'desc' => null, + 'obselete' => false, + 'sup' => null, + 'abstract' => false, + 'structural' => false, + 'auxiliary' => false, + 'must' => array(), + 'may' => array(), + ); + + /** + * Properties name aliases + * @var array + */ + protected static $property_aliases = array( + 'description' => 'desc', + 'superior' => 'sup', + ); + + /** + * Computed properties name + * @var array + */ + protected static $computed_properties = array( + 'names', + 'type', + ); + + /** + * Magic method to get objectclass schema entry key + * @param string $key + * @return mixed + * @throws \EesyLDAP\InvalidPropertyException + */ + public function __get($key) { + switch($key) { + case 'type': + if ($this->abstract) + return 'abstract'; + if ($this->structural) + return 'structural'; + if ($this->auxiliary) + return 'auxiliary'; + return null; + } + return parent::__get($key); + } +} diff --git a/src/Schema/SchemaEntry.php b/src/Schema/SchemaEntry.php new file mode 100644 index 0000000..d483b87 --- /dev/null +++ b/src/Schema/SchemaEntry.php @@ -0,0 +1,221 @@ + $names + */ +class SchemaEntry { + + /** + * Default properties value + * @var array + */ + protected static $default_properties = array( + 'oid' => null, + 'name' => array(), + ); + + /** + * Properties name aliases + * @var array + */ + protected static $property_aliases = array(); + + /** + * Computed properties name + * @var array + */ + protected static $computed_properties = array( + 'names', + ); + + /** + * Schema entry data parsed from SubSchema attribute value + * @see parse() + * @var array + */ + protected $data; + + /** + * Constructor + * @param array $data Parsed schema entry data + * @return void + */ + public function __construct($data) { + $this -> data = $data; + } + + /** + * Parses an SubSchema attribute value into a SchemaEntry object + * + * Note: mainly from PEAR Net_LDAP2_Schema::_parse_entry() + * @link https://pear.php.net/package/Net_LDAP2 + * + * @param string $value Attribute value + * + * @access protected + * @return SchemaEntry + */ + public static function parse($value) { + // tokens that have no value associated + $noValue = array( + 'single-value', + 'obsolete', + 'collective', + 'no-user-modification', + 'abstract', + 'structural', + 'auxiliary' + ); + + // tokens that can have multiple values + $multiValue = array('name', 'must', 'may', 'sup'); + + // initilization + $schema_entry = array(); + + $tokens = self :: _tokenize($value); // get an array of tokens + + // remove surrounding brackets + if ($tokens[0] == '(') array_shift($tokens); + if ($tokens[count($tokens) - 1] == ')') array_pop($tokens); // -1 doesnt work on arrays :-( + + $schema_entry['oid'] = array_shift($tokens); // first token is the oid + + // cycle over the tokens until none are left + while (count($tokens) > 0) { + $token = strtolower(array_shift($tokens)); + if (in_array($token, $noValue)) { + $schema_entry[$token] = 1; // single value token + } + else { + // this one follows a string or a list if it is multivalued + if (($schema_entry[$token] = array_shift($tokens)) == '(') { + // this creates the list of values and cycles through the tokens + // until the end of the list is reached ')' + $schema_entry[$token] = array(); + while ($tmp = array_shift($tokens)) { + if ($tmp == ')') break; + if ($tmp != '$') array_push($schema_entry[$token], $tmp); + } + } + // create a array if the value should be multivalued but was not + if (in_array($token, $multiValue) && !is_array($schema_entry[$token])) { + $schema_entry[$token] = array($schema_entry[$token]); + } + } + } + // get max length from syntax + if (key_exists('syntax', $schema_entry)) { + // @phpstan-ignore-next-line + if (preg_match('/{(\d+)}/', $schema_entry['syntax'], $matches)) { + $schema_entry['max_length'] = intval($matches[1]); + } + } + $type = get_called_class(); + return new $type($schema_entry); + } + + /** + * Tokenizes the given value into an array of tokens + * + * Note: mainly from PEAR Net_LDAP2_Schema::_tokenize() + * @link https://pear.php.net/package/Net_LDAP2 + * + * @param string $value String to parse + * + * @access protected + * @return array Array of tokens + */ + protected static function _tokenize($value) { + $tokens = array(); // array of tokens + $matches = array(); // matches[0] full pattern match, [1,2,3] subpatterns + + // this one is taken from perl-ldap, modified for php + $pattern = "/\s* (?:([()]) | ([^'\s()]+) | '((?:[^']+|'[^\s)])*)') \s*/x"; + + /** + * This one matches one big pattern wherin only one of the three subpatterns matched + * We are interested in the subpatterns that matched. If it matched its value will be + * non-empty and so it is a token. Tokens may be round brackets, a string, or a string + * enclosed by ' + */ + preg_match_all($pattern, $value, $matches); + + for ($i = 0; $i < count($matches[0]); $i++) { // number of tokens (full pattern match) + for ($j = 1; $j < 4; $j++) { // each subpattern + if (null != trim($matches[$j][$i])) { // pattern match in this subpattern + $tokens[$i] = trim($matches[$j][$i]); // this is the token + } + } + } + return $tokens; + } + + /** + * Magic method to get schema entry key + * @param string $key + * @return mixed + * @throws \EesyLDAP\InvalidPropertyException + */ + public function __get($key) { + if (array_key_exists($key, static :: $property_aliases)) + $key = static :: $property_aliases[$key]; + if ( + !array_key_exists($key, static :: $default_properties) + && !!array_key_exists($key, static :: $computed_properties) + ) + throw new \EesyLDAP\InvalidPropertyException( + "Invalid property '$key' requested on '".get_called_class()."'" + ); + switch($key) { + case 'name': + return ( + isset($this -> data['name']) && $this -> data['name']? + $this -> data['name'][0]:$this -> oid // @phpstan-ignore-line + ); + case 'names': + return ( + isset($this -> data['name']) && $this -> data['name']? + $this -> data['name']:array($this -> oid) + ); + } + $default = static :: $default_properties[$key]; + if (!array_key_exists($key, $this -> data)) + return $default; + if (is_bool($default)) + return boolval($this->data[$key]); + if (is_array($default) && !is_array($default)) + return is_null($this->data[$key])?array():array($this->data[$key]); + return $this->data[$key]; + } + + /** + * Magic method to compute string representation of this schema entry object + * @return string + */ + public function __toString() { + return sprintf('%s<%s>', get_called_class(), $this->name); + } + + /** + * Helper to check if the provided identifier match with this schema entry + * @param string $id + * @return bool + */ + public function is_me($id) { + if (!is_string($id)) + return false; + if ($id == $this->oid) + return true; + $id = strtolower($id); + foreach($this->names as $name) + if (strtolower($name) == $id) + return true; + return false; + } +} diff --git a/src/Schema/Syntax.php b/src/Schema/Syntax.php new file mode 100644 index 0000000..4958b8c --- /dev/null +++ b/src/Schema/Syntax.php @@ -0,0 +1,30 @@ + + */ + protected static $default_properties = array( + 'oid' => null, + 'desc' => null, + ); + + /** + * Properties name aliases + * @var array + */ + protected static $property_aliases = array( + 'description' => 'desc', + ); + +}