1# -*- coding: ascii -*- 2""" 3web2ldap.app.entry - schema-aware Entry classes 4 5web2ldap - a web-based LDAP Client, 6see https://www.web2ldap.de for details 7 8(c) 1998-2021 by Michael Stroeder <michael@stroeder.com> 9 10This software is distributed under the terms of the 11Apache License Version 2.0 (Apache-2.0) 12https://www.apache.org/licenses/LICENSE-2.0 13""" 14 15from collections import UserDict 16from io import BytesIO 17 18import ldap0.schema.models 19from ldap0.cidict import CIDict 20from ldap0.schema.models import ( 21 AttributeType, 22 ObjectClass, 23 SchemaElementOIDSet, 24) 25from ldap0.schema.subentry import SubSchema 26from ldap0.dn import DNObj 27from ldap0.ldif import LDIFWriter 28 29import web2ldapcnf 30 31from ..log import logger 32from ..msbase import GrabKeys 33 34from .tmpl import get_variant_filename 35from .gui import HIDDEN_FIELD 36from .schema import ( 37 NEEDS_BINARY_TAG, 38 no_userapp_attr, 39 object_class_categories, 40) 41from .schema.viewer import schema_anchor 42from .schema.syntaxes import ( 43 LDAPSyntaxValueError, 44 OctetString, 45 syntax_registry, 46) 47 48 49INPUT_FORM_LDIF_TMPL = """ 50<fieldset> 51 <legend>Raw LDIF data</legend> 52 <textarea name="in_ldif" rows="50" cols="80" wrap="off">{value_ldif}</textarea> 53 <p> 54 Notes: 55 </p> 56 <ul> 57 <li>Lines containing "dn:" will be ignored</li> 58 <li>Only the first entry (until first empty line) will be accepted</li> 59 <li>Maximum length is set to {value_ldifmaxbytes} bytes</li> 60 <li>Allowed URL schemes: {text_ldifurlschemes}</li> 61 </ul> 62</fieldset> 63""" 64 65 66class DisplayEntry(UserDict): 67 68 def __init__(self, app, dn, schema, entry, sep_attr, links): 69 assert isinstance(dn, str), TypeError("Argument 'dn' must be str, was %r" % (dn)) 70 assert isinstance(schema, SubSchema), \ 71 TypeError('Expected schema to be instance of SubSchema, was %r' % (schema)) 72 self._app = app 73 self.schema = schema 74 self.dn = dn 75 if isinstance(entry, dict): 76 self.entry = ldap0.schema.models.Entry(schema, dn, entry) 77 elif isinstance(entry, ldap0.schema.models.Entry): 78 self.entry = entry 79 else: 80 raise TypeError( 81 'Invalid type of argument entry, was %s.%s %r' % ( 82 entry.__class__.__module__, 83 entry.__class__.__name__, 84 entry, 85 ) 86 ) 87 self.soc = self.entry.get_structural_oc() 88 self.invalid_attrs = set() 89 self.sep_attr = sep_attr 90 self.links = links 91 92 def __getitem__(self, nameoroid): 93 try: 94 values = self.entry.__getitem__(nameoroid) 95 except KeyError: 96 return '' 97 result = [] 98 syntax_se = syntax_registry.get_syntax(self.entry._s, nameoroid, self.soc) 99 for i, value in enumerate(values): 100 attr_instance = syntax_se( 101 self._app, 102 self.dn, 103 self.entry._s, 104 nameoroid, 105 value, 106 self.entry, 107 ) 108 try: 109 attr_value_html = attr_instance.display(i, self.links) 110 except UnicodeError: 111 # Fall back to hex-dump output 112 attr_instance = OctetString( 113 self._app, 114 self.dn, 115 self.schema, 116 nameoroid, 117 value, 118 self.entry, 119 ) 120 attr_value_html = attr_instance.display(i, self.links) 121 try: 122 attr_instance.validate(value) 123 except LDAPSyntaxValueError: 124 attr_value_html = '<s>%s</s>' % (attr_value_html) 125 self.invalid_attrs.add(nameoroid) 126 result.append(attr_value_html) 127 if self.sep_attr is not None: 128 value_sep = getattr(attr_instance, self.sep_attr) 129 return value_sep.join(result) 130 return result 131 132 @property 133 def rdn_dict(self): 134 return DNObj.from_str(self.dn).rdn_attrs() 135 136 def get_html_templates(self, cnf_key): 137 read_template_dict = CIDict(self._app.cfg_param(cnf_key, {})) 138 # This gets all object classes no matter what 139 all_object_class_oid_set = self.entry.object_class_oid_set() 140 # Initialize the set with only the STRUCTURAL object class of the entry 141 object_class_oid_set = SchemaElementOIDSet( 142 self.entry._s, ldap0.schema.models.ObjectClass, [] 143 ) 144 structural_oc = self.entry.get_structural_oc() 145 if structural_oc: 146 object_class_oid_set.add(structural_oc) 147 # Now add the other AUXILIARY and ABSTRACT object classes 148 for ocl in all_object_class_oid_set: 149 ocl_obj = self.entry._s.get_obj(ldap0.schema.models.ObjectClass, ocl) 150 if ocl_obj is None or ocl_obj.kind != 0: 151 object_class_oid_set.add(ocl) 152 template_oc = object_class_oid_set.intersection(read_template_dict.data.keys()) 153 return template_oc.names, read_template_dict 154 # end of get_html_templates() 155 156 def template_output(self, cnf_key, display_duplicate_attrs=True): 157 # Determine relevant HTML templates 158 template_oc, read_template_dict = self.get_html_templates(cnf_key) 159 # Sort the object classes by object class category 160 structural_oc, abstract_oc, auxiliary_oc = object_class_categories( 161 self.entry._s, 162 template_oc, 163 ) 164 # Templates defined => display the entry with the help of the template 165 used_templates = set() 166 displayed_attrs = set() 167 for oc_set in (structural_oc, abstract_oc, auxiliary_oc): 168 for ocl in oc_set: 169 read_template_filename = read_template_dict[ocl] 170 logger.debug('Template file name %r defined for %r', read_template_dict[ocl], ocl) 171 if not read_template_filename: 172 logger.warning('Ignoring empty template file name for %r', ocl) 173 continue 174 read_template_filename = get_variant_filename( 175 read_template_filename, 176 self._app.form.accept_language, 177 ) 178 if read_template_filename in used_templates: 179 # template already processed 180 logger.debug( 181 'Skipping already processed template file name %r for %r', 182 read_template_dict[ocl], 183 ocl, 184 ) 185 continue 186 used_templates.add(read_template_filename) 187 try: 188 with open(read_template_filename, 'rb') as template_file: 189 template_str = template_file.read().decode('utf-8') 190 except IOError as err: 191 logger.error( 192 'Error reading template file %r for %r: %s', 193 read_template_dict[ocl], 194 ocl, 195 err, 196 ) 197 continue 198 template_attr_oid_set = { 199 self.entry._s.get_oid(ldap0.schema.models.AttributeType, attr_type_name) 200 for attr_type_name in GrabKeys(template_str)() 201 } 202 if ( 203 display_duplicate_attrs 204 or not displayed_attrs.intersection(template_attr_oid_set) 205 ): 206 self._app.outf.write(template_str % self) 207 displayed_attrs.update(template_attr_oid_set) 208 return displayed_attrs 209 210 211class InputFormEntry(DisplayEntry): 212 213 def __init__( 214 self, app, dn, schema, entry, 215 readonly_attr_oids, 216 existing_object_classes=None, 217 invalid_attrs=None 218 ): 219 assert isinstance(dn, str), TypeError("Argument 'dn' must be str, was {!r}".format(dn)) 220 DisplayEntry.__init__(self, app, dn, schema, entry, 'field_sep', False) 221 self.existing_object_classes = existing_object_classes 222 self.readonly_attr_oids = readonly_attr_oids 223 self.invalid_attrs = invalid_attrs or {} 224 new_object_classes = set(self.entry.object_class_oid_set()) - { 225 self.entry._s.get_oid(ObjectClass, oc_name) 226 for oc_name in existing_object_classes or [] 227 } 228 new_attribute_types = self.entry._s.attribute_types( 229 new_object_classes, 230 raise_keyerror=0, 231 ignore_dit_content_rule=self._app.ls.relax_rules 232 ) 233 old_attribute_types = self.entry._s.attribute_types( 234 existing_object_classes or [], 235 raise_keyerror=0, 236 ignore_dit_content_rule=self._app.ls.relax_rules 237 ) 238 self.new_attribute_types_oids = set() 239 self.new_attribute_types_oids.update(new_attribute_types[0].keys()) 240 self.new_attribute_types_oids.update(new_attribute_types[1].keys()) 241 for at_oid in list(old_attribute_types[0].keys())+list(old_attribute_types[1].keys()): 242 try: 243 self.new_attribute_types_oids.remove(at_oid) 244 except KeyError: 245 pass 246 247 def _reset_input_counters(self): 248 self.attr_counter = 0 249 self.row_counter = 0 250 # end of _reset_input_counters() 251 252 def __getitem__(self, nameoroid): 253 """ 254 Return HTML input field(s) for the attribute specified by nameoroid. 255 """ 256 oid = self.entry.name2key(nameoroid)[0] 257 nameoroid_se = self.entry._s.get_obj(AttributeType, nameoroid) 258 syntax_class = syntax_registry.get_syntax(self.entry._s, nameoroid, self.soc) 259 try: 260 attr_values = self.entry.__getitem__(nameoroid) 261 except KeyError: 262 attr_values = [] 263 # Attribute value list must contain at least one element to display an input field 264 attr_values = attr_values or [None] 265 266 result = [] 267 268 # Eliminate binary attribute values from input form 269 if not syntax_class.editable: 270 attr_values = [b''] 271 272 invalid_attr_indexes = set(self.invalid_attrs.get(nameoroid, [])) 273 274 for attr_index, attr_value in enumerate(attr_values): 275 276 attr_inst = syntax_class( 277 self._app, self.dn, self.entry._s, nameoroid, attr_value, self.entry, 278 ) 279 highlight_invalid = attr_index in invalid_attr_indexes 280 281 if ( 282 # Attribute type 'objectClass' always read-only here 283 oid == '2.5.4.0' 284 ) or ( 285 # Attribute type 'structuralObjectClass' always read-only no matter what 286 oid == '2.5.21.9' 287 ) or ( 288 # Check whether the server indicated this attribute 289 # not to be writeable by bound identity 290 not self.readonly_attr_oids is None and 291 oid in self.readonly_attr_oids and 292 not oid in self.new_attribute_types_oids 293 ) or ( 294 # Check whether attribute type/value is used in the RDN => not writeable 295 self.existing_object_classes and 296 attr_value and 297 nameoroid in self.rdn_dict and 298 self.rdn_dict[nameoroid].encode('utf-8') == attr_value 299 ) or ( 300 # Set to writeable if relax rules control is in effect 301 # and attribute is NO-USER-APP in subschema 302 not self._app.ls.relax_rules and 303 no_userapp_attr(self.entry._s, oid) 304 ): 305 result.append('\n'.join(( 306 '<span class="InvalidInput">'*highlight_invalid, 307 self._app.form.hidden_field_html('in_at', nameoroid, ''), 308 HIDDEN_FIELD % ('in_avi', str(self.attr_counter), ''), 309 HIDDEN_FIELD % ( 310 'in_av', 311 self._app.form.s2d(attr_inst.form_value(), sp_entity=' '), 312 self._app.form.s2d(attr_inst.form_value(), sp_entity=' ') 313 ), 314 '</span>'*highlight_invalid, 315 ))) 316 self.row_counter += 1 317 318 else: 319 attr_title = '' 320 attr_type_tags = [] 321 attr_type_name = str(nameoroid).split(';')[0] 322 if nameoroid_se: 323 attr_type_name = (nameoroid_se.names or [nameoroid_se.oid])[0] 324 try: 325 attr_title = (nameoroid_se.desc or '') 326 except UnicodeError: 327 # This happens sometimes because of wrongly encoded schema files 328 attr_title = '' 329 # Determine whether transfer syntax has to be specified with ;binary 330 if ( 331 nameoroid.endswith(';binary') or 332 oid in NEEDS_BINARY_TAG or 333 nameoroid_se.syntax in NEEDS_BINARY_TAG 334 ): 335 attr_type_tags.append('binary') 336 input_fields = attr_inst.input_fields() 337 for input_field in input_fields: 338 input_field.name = 'in_av' 339 input_field.charset = self._app.form.accept_charset 340 result.append('\n'.join([ 341 '<span class="InvalidInput">'*highlight_invalid, 342 HIDDEN_FIELD % ( 343 'in_at', 344 ';'.join([attr_type_name]+attr_type_tags), 345 '' 346 ), 347 348 HIDDEN_FIELD % ('in_avi', str(self.attr_counter), ''), 349 input_field.input_html( 350 id_value='_'.join(( 351 'inputattr', attr_type_name, str(attr_index) 352 )), 353 title=attr_title 354 ), 355 attr_inst.value_button(self._app.command, self.row_counter, '+'), 356 attr_inst.value_button(self._app.command, self.row_counter, '-'), 357 '</span>'*highlight_invalid, 358 ])) 359 self.row_counter += 1 360 361 self.attr_counter += 1 362 363 return '<a class="hide" id="in_a_%s"></a>%s' % ( 364 self._app.form.s2d(nameoroid), 365 '\n<br>\n'.join(result), 366 ) 367 368 def attribute_types(self): 369 # Initialize a list of assertions for filtering attribute types 370 # displayed in the input form 371 attr_type_filter = [ 372 ('no_user_mod', [0]), 373 #('usage', range(2)), 374 ('collective', [0]), 375 ] 376 # Check whether Manage DIT control is in effect, 377 # filter out OBSOLETE attribute types otherwise 378 if not self._app.ls.relax_rules: 379 attr_type_filter.append(('obsolete', [0])) 380 381 # Filter out extensibleObject 382 object_class_oids = self.entry.object_class_oid_set() 383 try: 384 object_class_oids.remove('1.3.6.1.4.1.1466.101.120.111') 385 except KeyError: 386 pass 387 try: 388 object_class_oids.remove('extensibleObject') 389 except KeyError: 390 pass 391 392 required_attrs_dict, allowed_attrs_dict = self.entry._s.attribute_types( 393 list(object_class_oids), 394 attr_type_filter=attr_type_filter, 395 raise_keyerror=0, 396 ignore_dit_content_rule=self._app.ls.relax_rules, 397 ) 398 399 # Additional check whether to explicitly add object class attribute. 400 # This is a work-around for LDAP servers which mark the 401 # objectClass attribute as not modifiable (e.g. MS Active Directory) 402 if '2.5.4.0' not in required_attrs_dict and '2.5.4.0' not in allowed_attrs_dict: 403 required_attrs_dict['2.5.4.0'] = self.entry._s.get_obj(ObjectClass, '2.5.4.0') 404 return required_attrs_dict, allowed_attrs_dict 405 406 def fieldset_table(self, attr_types_dict, fieldset_title): 407 self._app.outf.write( 408 """<fieldset title="%s"> 409 <legend>%s</legend> 410 <table summary="%s"> 411 """ % (fieldset_title, fieldset_title, fieldset_title) 412 ) 413 seen_attr_type_oids = ldap0.cidict.CIDict() 414 attr_type_names = ldap0.cidict.CIDict() 415 for atype in self.entry.keys(): 416 at_oid = self.entry.name2key(atype)[0] 417 if at_oid in attr_types_dict: 418 seen_attr_type_oids[at_oid] = None 419 attr_type_names[atype] = None 420 for at_oid, at_se in attr_types_dict.items(): 421 if ( 422 at_se and 423 at_oid not in seen_attr_type_oids and 424 not no_userapp_attr(self.entry._s, at_oid) 425 ): 426 attr_type_names[(at_se.names or (at_se.oid,))[0]] = None 427 attr_types = list(attr_type_names.keys()) 428 attr_types.sort(key=str.lower) 429 for attr_type in attr_types: 430 attr_type_name = schema_anchor(self._app, attr_type, AttributeType, link_text='»') 431 attr_value_field_html = self[attr_type] 432 self._app.outf.write( 433 '<tr>\n<td class="InputAttrType">\n%s\n</td>\n<td>\n%s\n</td>\n</tr>\n' % ( 434 attr_type_name, 435 attr_value_field_html, 436 ) 437 ) 438 self._app.outf.write('</table>\n</fieldset>\n') 439 # end of fieldset_table() 440 441 def table_input(self, attrs_dict_list): 442 self._reset_input_counters() 443 for attr_dict, fieldset_title in attrs_dict_list: 444 if attr_dict: 445 self.fieldset_table(attr_dict, fieldset_title) 446 # end of table_input() 447 448 def template_output(self, cnf_key, display_duplicate_attrs=True): 449 self._reset_input_counters() 450 displayed_attrs = DisplayEntry.template_output( 451 self, cnf_key, display_duplicate_attrs=display_duplicate_attrs 452 ) 453 # Output hidden fields for attributes not displayed in template-based input form 454 for attr_type, attr_values in self.entry.items(): 455 at_oid = self.entry.name2key(attr_type)[0] 456 syntax_class = syntax_registry.get_syntax(self.entry._s, attr_type, self.soc) 457 if syntax_class.editable and \ 458 not no_userapp_attr(self.entry._s, attr_type) and \ 459 not at_oid in displayed_attrs: 460 for attr_value in attr_values: 461 attr_inst = syntax_class( 462 self._app, self.dn, self.entry._s, attr_type, attr_value, self.entry 463 ) 464 self._app.outf.write(self._app.form.hidden_field_html('in_at', attr_type, '')) 465 self._app.outf.write(HIDDEN_FIELD % ('in_avi', str(self.attr_counter), '')) 466 try: 467 attr_value_html = self._app.form.s2d(attr_inst.form_value(), sp_entity=' ') 468 except UnicodeDecodeError: 469 # Simply display an empty string if anything goes wrong with Unicode 470 # decoding (e.g. with binary attributes) 471 attr_value_html = '' 472 self._app.outf.write(HIDDEN_FIELD % ( 473 'in_av', attr_value_html, '' 474 )) 475 self.attr_counter += 1 476 return displayed_attrs # template_output() 477 478 def ldif_input(self): 479 bio = BytesIO() 480 ldif_writer = LDIFWriter(bio) 481 ldap_entry = {} 482 for attr_type in self.entry.keys(): 483 attr_values = self.entry.__getitem__(attr_type) 484 if not no_userapp_attr(self.entry._s, attr_type): 485 ldap_entry[attr_type.encode('ascii')] = [ 486 attr_value 487 for attr_value in attr_values 488 if attr_value 489 ] 490 ldif_writer.unparse(self.dn.encode(self._app.ls.charset), ldap_entry) 491 self._app.outf.write( 492 INPUT_FORM_LDIF_TMPL.format( 493 value_ldif=self._app.form.s2d( 494 bio.getvalue().decode('utf-8'), 495 sp_entity=' ', 496 lf_entity='\n', 497 ), 498 value_ldifmaxbytes=web2ldapcnf.ldif_maxbytes, 499 text_ldifurlschemes=', '.join(web2ldapcnf.ldif_url_schemes) 500 ) 501 ) 502 # end of ldif_input() 503