OpenStack Identity (Keystone)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

core.py 68KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532
  1. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  2. # not use this file except in compliance with the License. You may obtain
  3. # a copy of the License at
  4. #
  5. # http://www.apache.org/licenses/LICENSE-2.0
  6. #
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  9. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  10. # License for the specific language governing permissions and limitations
  11. # under the License.
  12. """Main entry point into the Resource service."""
  13. from oslo_log import log
  14. import six
  15. from keystone import assignment
  16. from keystone.common import cache
  17. from keystone.common import driver_hints
  18. from keystone.common import manager
  19. from keystone.common import provider_api
  20. from keystone.common import utils
  21. import keystone.conf
  22. from keystone import exception
  23. from keystone.i18n import _
  24. from keystone import notifications
  25. from keystone.resource.backends import base
  26. from keystone.resource.backends import sql as resource_sql
  27. from keystone.token import provider as token_provider
  28. CONF = keystone.conf.CONF
  29. LOG = log.getLogger(__name__)
  30. MEMOIZE = cache.get_memoization_decorator(group='resource')
  31. PROVIDERS = provider_api.ProviderAPIs
  32. TAG_SEARCH_FILTERS = ('tags', 'tags-any', 'not-tags', 'not-tags-any')
  33. class Manager(manager.Manager):
  34. """Default pivot point for the Resource backend.
  35. See :mod:`keystone.common.manager.Manager` for more details on how this
  36. dynamically calls the backend.
  37. """
  38. driver_namespace = 'keystone.resource'
  39. _provides_api = 'resource_api'
  40. _DOMAIN = 'domain'
  41. _PROJECT = 'project'
  42. _PROJECT_TAG = 'project tag'
  43. def __init__(self):
  44. # NOTE(morgan): The resource driver must be SQL. This is because there
  45. # is a FK between identity and resource. Almost every deployment uses
  46. # SQL Identity in some form. Even if SQL Identity is not used, there
  47. # is almost no reason to have non-SQL Resource. Keystone requires
  48. # SQL in a number of ways, this simply codifies it plainly for resource
  49. # the driver_name = None simply implies we don't need to load a driver.
  50. self.driver = resource_sql.Resource()
  51. super(Manager, self).__init__(driver_name=None)
  52. def _get_hierarchy_depth(self, parents_list):
  53. return len(parents_list) + 1
  54. def _assert_max_hierarchy_depth(self, project_id, parents_list=None):
  55. if parents_list is None:
  56. parents_list = self.list_project_parents(project_id)
  57. # NOTE(henry-nash): In upgrading to a scenario where domains are
  58. # represented as projects acting as domains, we will effectively
  59. # increase the depth of any existing project hierarchy by one. To avoid
  60. # pushing any existing hierarchies over the limit, we add one to the
  61. # maximum depth allowed, as specified in the configuration file.
  62. max_depth = CONF.max_project_tree_depth + 1
  63. # NOTE(wxy): If the hierarchical limit enforcement model is used, the
  64. # project depth should be not greater than the model's limit as well.
  65. #
  66. # TODO(wxy): Deprecate and remove CONF.max_project_tree_depth, let the
  67. # depth check only based on the limit enforcement model.
  68. limit_model = PROVIDERS.unified_limit_api.enforcement_model
  69. if limit_model.MAX_PROJECT_TREE_DEPTH is not None:
  70. max_depth = min(max_depth, limit_model.MAX_PROJECT_TREE_DEPTH + 1)
  71. if self._get_hierarchy_depth(parents_list) > max_depth:
  72. raise exception.ForbiddenNotSecurity(
  73. _('Max hierarchy depth reached for %s branch.') % project_id)
  74. def _assert_is_domain_project_constraints(self, project_ref):
  75. """Enforce specific constraints of projects that act as domains.
  76. Called when is_domain is true, this method ensures that:
  77. * multiple domains are enabled
  78. * the project name is not the reserved name for a federated domain
  79. * the project is a root project
  80. :raises keystone.exception.ValidationError: If one of the constraints
  81. was not satisfied.
  82. """
  83. if (not PROVIDERS.identity_api.multiple_domains_supported and
  84. project_ref['id'] != CONF.identity.default_domain_id and
  85. project_ref['id'] != base.NULL_DOMAIN_ID):
  86. raise exception.ValidationError(
  87. message=_('Multiple domains are not supported'))
  88. self.assert_domain_not_federated(project_ref['id'], project_ref)
  89. if project_ref['parent_id']:
  90. raise exception.ValidationError(
  91. message=_('only root projects are allowed to act as '
  92. 'domains.'))
  93. def _assert_regular_project_constraints(self, project_ref):
  94. """Enforce regular project hierarchy constraints.
  95. Called when is_domain is false. The project must contain a valid
  96. domain_id and parent_id. The goal of this method is to check
  97. that the domain_id specified is consistent with the domain of its
  98. parent.
  99. :raises keystone.exception.ValidationError: If one of the constraints
  100. was not satisfied.
  101. :raises keystone.exception.DomainNotFound: In case the domain is not
  102. found.
  103. """
  104. # Ensure domain_id is valid, and by inference will not be None.
  105. domain = self.get_domain(project_ref['domain_id'])
  106. parent_ref = self.get_project(project_ref['parent_id'])
  107. if parent_ref['is_domain']:
  108. if parent_ref['id'] != domain['id']:
  109. raise exception.ValidationError(
  110. message=_('Cannot create project, the parent '
  111. '(%(parent_id)s) is acting as a domain, '
  112. 'but this project\'s domain id (%(domain_id)s) '
  113. 'does not match the parent\'s id.')
  114. % {'parent_id': parent_ref['id'],
  115. 'domain_id': domain['id']})
  116. else:
  117. parent_domain_id = parent_ref.get('domain_id')
  118. if parent_domain_id != domain['id']:
  119. raise exception.ValidationError(
  120. message=_('Cannot create project, since it specifies '
  121. 'its domain_id %(domain_id)s, but '
  122. 'specifies a parent in a different domain '
  123. '(%(parent_domain_id)s).')
  124. % {'domain_id': domain['id'],
  125. 'parent_domain_id': parent_domain_id})
  126. def _enforce_project_constraints(self, project_ref):
  127. if project_ref.get('is_domain'):
  128. self._assert_is_domain_project_constraints(project_ref)
  129. else:
  130. self._assert_regular_project_constraints(project_ref)
  131. # The whole hierarchy (upwards) must be enabled
  132. parent_id = project_ref['parent_id']
  133. parents_list = self.list_project_parents(parent_id)
  134. parent_ref = self.get_project(parent_id)
  135. parents_list.append(parent_ref)
  136. for ref in parents_list:
  137. if not ref.get('enabled', True):
  138. raise exception.ValidationError(
  139. message=_('cannot create a project in a '
  140. 'branch containing a disabled '
  141. 'project: %s') % ref['id'])
  142. self._assert_max_hierarchy_depth(project_ref.get('parent_id'),
  143. parents_list)
  144. def _raise_reserved_character_exception(self, entity_type, name):
  145. msg = _('%(entity)s name cannot contain the following reserved '
  146. 'characters: %(chars)s')
  147. raise exception.ValidationError(
  148. message=msg % {
  149. 'entity': entity_type,
  150. 'chars': utils.list_url_unsafe_chars(name)
  151. })
  152. def _generate_project_name_conflict_msg(self, project):
  153. if project['is_domain']:
  154. return _('it is not permitted to have two projects '
  155. 'acting as domains with the same name: %s'
  156. ) % project['name']
  157. else:
  158. return _('it is not permitted to have two projects '
  159. 'with either the same name or same id in '
  160. 'the same domain: '
  161. 'name is %(name)s, project id %(id)s'
  162. ) % project
  163. def create_project(self, project_id, project, initiator=None):
  164. project = project.copy()
  165. if (CONF.resource.project_name_url_safe != 'off' and
  166. utils.is_not_url_safe(project['name'])):
  167. self._raise_reserved_character_exception('Project',
  168. project['name'])
  169. project.setdefault('enabled', True)
  170. project['name'] = project['name'].strip()
  171. project.setdefault('description', '')
  172. # For regular projects, the controller will ensure we have a valid
  173. # domain_id. For projects acting as a domain, the project_id
  174. # is, effectively, the domain_id - and for such projects we don't
  175. # bother to store a copy of it in the domain_id attribute.
  176. project.setdefault('domain_id', None)
  177. project.setdefault('parent_id', None)
  178. if not project['parent_id']:
  179. project['parent_id'] = project['domain_id']
  180. project.setdefault('is_domain', False)
  181. self._enforce_project_constraints(project)
  182. # We leave enforcing name uniqueness to the underlying driver (instead
  183. # of doing it in code in the project_constraints above), so as to allow
  184. # this check to be done at the storage level, avoiding race conditions
  185. # in multi-process keystone configurations.
  186. try:
  187. ret = self.driver.create_project(project_id, project)
  188. except exception.Conflict:
  189. raise exception.Conflict(
  190. type='project',
  191. details=self._generate_project_name_conflict_msg(project))
  192. if project.get('is_domain'):
  193. notifications.Audit.created(self._DOMAIN, project_id, initiator)
  194. else:
  195. notifications.Audit.created(self._PROJECT, project_id, initiator)
  196. if MEMOIZE.should_cache(ret):
  197. self.get_project.set(ret, self, project_id)
  198. self.get_project_by_name.set(ret, self, ret['name'],
  199. ret['domain_id'])
  200. assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  201. return ret
  202. def assert_domain_enabled(self, domain_id, domain=None):
  203. """Assert the Domain is enabled.
  204. :raise AssertionError: if domain is disabled.
  205. """
  206. if domain is None:
  207. domain = self.get_domain(domain_id)
  208. if not domain.get('enabled', True):
  209. raise AssertionError(_('Domain is disabled: %s') % domain_id)
  210. def assert_domain_not_federated(self, domain_id, domain):
  211. """Assert the Domain's name and id do not match the reserved keyword.
  212. Note that the reserved keyword is defined in the configuration file,
  213. by default, it is 'Federated', it is also case insensitive.
  214. If config's option is empty the default hardcoded value 'Federated'
  215. will be used.
  216. :raise AssertionError: if domain named match the value in the config.
  217. """
  218. # NOTE(marek-denis): We cannot create this attribute in the __init__ as
  219. # config values are always initialized to default value.
  220. federated_domain = CONF.federation.federated_domain_name.lower()
  221. if (domain.get('name') and domain['name'].lower() == federated_domain):
  222. raise AssertionError(_('Domain cannot be named %s')
  223. % domain['name'])
  224. if (domain_id.lower() == federated_domain):
  225. raise AssertionError(_('Domain cannot have ID %s')
  226. % domain_id)
  227. def assert_project_enabled(self, project_id, project=None):
  228. """Assert the project is enabled and its associated domain is enabled.
  229. :raise AssertionError: if the project or domain is disabled.
  230. """
  231. if project is None:
  232. project = self.get_project(project_id)
  233. # If it's a regular project (i.e. it has a domain_id), we need to make
  234. # sure the domain itself is not disabled
  235. if project['domain_id']:
  236. self.assert_domain_enabled(domain_id=project['domain_id'])
  237. if not project.get('enabled', True):
  238. raise AssertionError(_('Project is disabled: %s') % project_id)
  239. def _assert_all_parents_are_enabled(self, project_id):
  240. parents_list = self.list_project_parents(project_id)
  241. for project in parents_list:
  242. if not project.get('enabled', True):
  243. raise exception.ForbiddenNotSecurity(
  244. _('Cannot enable project %s since it has disabled '
  245. 'parents') % project_id)
  246. def _check_whole_subtree_is_disabled(self, project_id, subtree_list=None):
  247. if not subtree_list:
  248. subtree_list = self.list_projects_in_subtree(project_id)
  249. subtree_enabled = [ref.get('enabled', True) for ref in subtree_list]
  250. return (not any(subtree_enabled))
  251. def _update_project(self, project_id, project, initiator=None,
  252. cascade=False):
  253. # Use the driver directly to prevent using old cached value.
  254. original_project = self.driver.get_project(project_id)
  255. project = project.copy()
  256. self._require_matching_domain_id(project, original_project)
  257. if original_project['is_domain']:
  258. domain = self._get_domain_from_project(original_project)
  259. self.assert_domain_not_federated(project_id, domain)
  260. url_safe_option = CONF.resource.domain_name_url_safe
  261. exception_entity = 'Domain'
  262. else:
  263. url_safe_option = CONF.resource.project_name_url_safe
  264. exception_entity = 'Project'
  265. project_name_changed = ('name' in project and project['name'] !=
  266. original_project['name'])
  267. if (url_safe_option != 'off' and project_name_changed and
  268. utils.is_not_url_safe(project['name'])):
  269. self._raise_reserved_character_exception(exception_entity,
  270. project['name'])
  271. elif project_name_changed:
  272. project['name'] = project['name'].strip()
  273. parent_id = original_project.get('parent_id')
  274. if 'parent_id' in project and project.get('parent_id') != parent_id:
  275. raise exception.ForbiddenNotSecurity(
  276. _('Update of `parent_id` is not allowed.'))
  277. if ('is_domain' in project and
  278. project['is_domain'] != original_project['is_domain']):
  279. raise exception.ValidationError(
  280. message=_('Update of `is_domain` is not allowed.'))
  281. original_project_enabled = original_project.get('enabled', True)
  282. project_enabled = project.get('enabled', True)
  283. if not original_project_enabled and project_enabled:
  284. self._assert_all_parents_are_enabled(project_id)
  285. if original_project_enabled and not project_enabled:
  286. # NOTE(htruta): In order to disable a regular project, all its
  287. # children must already be disabled. However, to keep
  288. # compatibility with the existing domain behaviour, we allow a
  289. # project acting as a domain to be disabled irrespective of the
  290. # state of its children. Disabling a project acting as domain
  291. # effectively disables its children.
  292. if (not original_project.get('is_domain') and not cascade and not
  293. self._check_whole_subtree_is_disabled(project_id)):
  294. raise exception.ForbiddenNotSecurity(
  295. _('Cannot disable project %(project_id)s since its '
  296. 'subtree contains enabled projects.')
  297. % {'project_id': project_id})
  298. notifications.Audit.disabled(self._PROJECT, project_id,
  299. public=False)
  300. # Drop the computed assignments if the project is being disabled.
  301. # This ensures an accurate list of projects is returned when
  302. # listing projects/domains for a user based on role assignments.
  303. assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  304. if cascade:
  305. self._only_allow_enabled_to_update_cascade(project,
  306. original_project)
  307. self._update_project_enabled_cascade(project_id, project_enabled)
  308. try:
  309. project['is_domain'] = (project.get('is_domain') or
  310. original_project['is_domain'])
  311. ret = self.driver.update_project(project_id, project)
  312. except exception.Conflict:
  313. raise exception.Conflict(
  314. type='project',
  315. details=self._generate_project_name_conflict_msg(project))
  316. try:
  317. self.get_project.invalidate(self, project_id)
  318. self.get_project_by_name.invalidate(self, original_project['name'],
  319. original_project['domain_id'])
  320. if ('domain_id' in project and
  321. project['domain_id'] != original_project['domain_id']):
  322. # If the project's domain_id has been updated, invalidate user
  323. # role assignments cache region, as it may be caching inherited
  324. # assignments from the old domain to the specified project
  325. assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  326. finally:
  327. # attempt to send audit event even if the cache invalidation raises
  328. notifications.Audit.updated(self._PROJECT, project_id, initiator)
  329. if original_project['is_domain']:
  330. notifications.Audit.updated(self._DOMAIN, project_id,
  331. initiator)
  332. # If the domain is being disabled, issue the disable
  333. # notification as well
  334. if original_project_enabled and not project_enabled:
  335. # NOTE(lbragstad): When a domain is disabled, we have to
  336. # invalidate the entire token cache. With persistent
  337. # tokens, we did something similar where all tokens for a
  338. # specific domain were deleted when that domain was
  339. # disabled. This effectively offers the same behavior for
  340. # non-persistent tokens by removing them from the cache and
  341. # requiring the authorization context to be rebuilt the
  342. # next time they're validated.
  343. token_provider.TOKENS_REGION.invalidate()
  344. notifications.Audit.disabled(self._DOMAIN, project_id,
  345. public=False)
  346. return ret
  347. def _only_allow_enabled_to_update_cascade(self, project, original_project):
  348. for attr in project:
  349. if attr != 'enabled':
  350. if project.get(attr) != original_project.get(attr):
  351. raise exception.ValidationError(
  352. message=_('Cascade update is only allowed for '
  353. 'enabled attribute.'))
  354. def _update_project_enabled_cascade(self, project_id, enabled):
  355. subtree = self.list_projects_in_subtree(project_id)
  356. # Update enabled only if different from original value
  357. subtree_to_update = [child for child in subtree
  358. if child['enabled'] != enabled]
  359. for child in subtree_to_update:
  360. child['enabled'] = enabled
  361. if not enabled:
  362. # Does not in fact disable the project, only emits a
  363. # notification that it was disabled. The actual disablement
  364. # is done in the next line.
  365. notifications.Audit.disabled(self._PROJECT, child['id'],
  366. public=False)
  367. self.driver.update_project(child['id'], child)
  368. def update_project(self, project_id, project, initiator=None,
  369. cascade=False):
  370. ret = self._update_project(project_id, project, initiator, cascade)
  371. if ret['is_domain']:
  372. self.get_domain.invalidate(self, project_id)
  373. self.get_domain_by_name.invalidate(self, ret['name'])
  374. return ret
  375. def _post_delete_cleanup_project(self, project_id, project,
  376. initiator=None):
  377. try:
  378. self.get_project.invalidate(self, project_id)
  379. self.get_project_by_name.invalidate(self, project['name'],
  380. project['domain_id'])
  381. PROVIDERS.assignment_api.delete_project_assignments(project_id)
  382. # Invalidate user role assignments cache region, as it may
  383. # be caching role assignments where the target is
  384. # the specified project
  385. assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  386. PROVIDERS.credential_api.delete_credentials_for_project(project_id)
  387. PROVIDERS.trust_api.delete_trusts_for_project(project_id)
  388. PROVIDERS.unified_limit_api.delete_limits_for_project(project_id)
  389. finally:
  390. # attempt to send audit event even if the cache invalidation raises
  391. notifications.Audit.deleted(self._PROJECT, project_id, initiator)
  392. def delete_project(self, project_id, initiator=None, cascade=False):
  393. """Delete one project or a subtree.
  394. :param cascade: If true, the specified project and all its
  395. sub-projects are deleted. Otherwise, only the specified
  396. project is deleted.
  397. :type cascade: boolean
  398. :raises keystone.exception.ValidationError: if project is a domain
  399. :raises keystone.exception.Forbidden: if project is not a leaf
  400. """
  401. project = self.driver.get_project(project_id)
  402. if project.get('is_domain'):
  403. self._delete_domain(project, initiator)
  404. else:
  405. self._delete_project(project, initiator, cascade)
  406. def _delete_project(self, project, initiator=None, cascade=False):
  407. project_id = project['id']
  408. if project['is_domain'] and project['enabled']:
  409. raise exception.ValidationError(
  410. message=_('cannot delete an enabled project acting as a '
  411. 'domain. Please disable the project %s first.')
  412. % project.get('id'))
  413. if not self.is_leaf_project(project_id) and not cascade:
  414. raise exception.ForbiddenNotSecurity(
  415. _('Cannot delete the project %s since it is not a leaf in the '
  416. 'hierarchy. Use the cascade option if you want to delete a '
  417. 'whole subtree.')
  418. % project_id)
  419. if cascade:
  420. # Getting reversed project's subtrees list, i.e. from the leaves
  421. # to the root, so we do not break parent_id FK.
  422. subtree_list = self.list_projects_in_subtree(project_id)
  423. subtree_list.reverse()
  424. if not self._check_whole_subtree_is_disabled(
  425. project_id, subtree_list=subtree_list):
  426. raise exception.ForbiddenNotSecurity(
  427. _('Cannot delete project %(project_id)s since its subtree '
  428. 'contains enabled projects.')
  429. % {'project_id': project_id})
  430. project_list = subtree_list + [project]
  431. projects_ids = [x['id'] for x in project_list]
  432. ret = self.driver.delete_projects_from_ids(projects_ids)
  433. for prj in project_list:
  434. self._post_delete_cleanup_project(prj['id'], prj, initiator)
  435. else:
  436. ret = self.driver.delete_project(project_id)
  437. self._post_delete_cleanup_project(project_id, project, initiator)
  438. reason = (
  439. 'The token cache is being invalidate because project '
  440. '%(project_id)s was deleted. Authorization will be recalculated '
  441. 'and enforced accordingly the next time users authenticate or '
  442. 'validate a token.' % {'project_id': project_id}
  443. )
  444. notifications.invalidate_token_cache_notification(reason)
  445. return ret
  446. def _filter_projects_list(self, projects_list, user_id):
  447. user_projects = PROVIDERS.assignment_api.list_projects_for_user(
  448. user_id
  449. )
  450. user_projects_ids = set([proj['id'] for proj in user_projects])
  451. # Keep only the projects present in user_projects
  452. return [proj for proj in projects_list
  453. if proj['id'] in user_projects_ids]
  454. def _assert_valid_project_id(self, project_id):
  455. if project_id is None:
  456. msg = _('Project field is required and cannot be empty.')
  457. raise exception.ValidationError(message=msg)
  458. # Check if project_id exists
  459. self.get_project(project_id)
  460. def _include_limits(self, projects):
  461. """Modify a list of projects to include limit information.
  462. :param projects: a list of project references including an `id`
  463. :type projects: list of dictionaries
  464. """
  465. for project in projects:
  466. hints = driver_hints.Hints()
  467. hints.add_filter('project_id', project['id'])
  468. limits = PROVIDERS.unified_limit_api.list_limits(hints)
  469. project['limits'] = limits
  470. def list_project_parents(self, project_id, user_id=None,
  471. include_limits=False):
  472. self._assert_valid_project_id(project_id)
  473. parents = self.driver.list_project_parents(project_id)
  474. # If a user_id was provided, the returned list should be filtered
  475. # against the projects this user has access to.
  476. if user_id:
  477. parents = self._filter_projects_list(parents, user_id)
  478. if include_limits:
  479. self._include_limits(parents)
  480. return parents
  481. def _build_parents_as_ids_dict(self, project, parents_by_id):
  482. # NOTE(rodrigods): we don't rely in the order of the projects returned
  483. # by the list_project_parents() method. Thus, we create a project cache
  484. # (parents_by_id) in order to access each parent in constant time and
  485. # traverse up the hierarchy.
  486. def traverse_parents_hierarchy(project):
  487. parent_id = project.get('parent_id')
  488. if not parent_id:
  489. return None
  490. parent = parents_by_id[parent_id]
  491. return {parent_id: traverse_parents_hierarchy(parent)}
  492. return traverse_parents_hierarchy(project)
  493. def get_project_parents_as_ids(self, project):
  494. """Get the IDs from the parents from a given project.
  495. The project IDs are returned as a structured dictionary traversing up
  496. the hierarchy to the top level project. For example, considering the
  497. following project hierarchy::
  498. A
  499. |
  500. +-B-+
  501. | |
  502. C D
  503. If we query for project C parents, the expected return is the following
  504. dictionary::
  505. 'parents': {
  506. B['id']: {
  507. A['id']: None
  508. }
  509. }
  510. """
  511. parents_list = self.list_project_parents(project['id'])
  512. parents_as_ids = self._build_parents_as_ids_dict(
  513. project, {proj['id']: proj for proj in parents_list})
  514. return parents_as_ids
  515. def list_projects_in_subtree(self, project_id, user_id=None,
  516. include_limits=False):
  517. self._assert_valid_project_id(project_id)
  518. subtree = self.driver.list_projects_in_subtree(project_id)
  519. # If a user_id was provided, the returned list should be filtered
  520. # against the projects this user has access to.
  521. if user_id:
  522. subtree = self._filter_projects_list(subtree, user_id)
  523. if include_limits:
  524. self._include_limits(subtree)
  525. return subtree
  526. def _build_subtree_as_ids_dict(self, project_id, subtree_by_parent):
  527. # NOTE(rodrigods): we perform a depth first search to construct the
  528. # dictionaries representing each level of the subtree hierarchy. In
  529. # order to improve this traversal performance, we create a cache of
  530. # projects (subtree_py_parent) that accesses in constant time the
  531. # direct children of a given project.
  532. def traverse_subtree_hierarchy(project_id):
  533. children = subtree_by_parent.get(project_id)
  534. if not children:
  535. return None
  536. children_ids = {}
  537. for child in children:
  538. children_ids[child['id']] = traverse_subtree_hierarchy(
  539. child['id'])
  540. return children_ids
  541. return traverse_subtree_hierarchy(project_id)
  542. def get_projects_in_subtree_as_ids(self, project_id):
  543. """Get the IDs from the projects in the subtree from a given project.
  544. The project IDs are returned as a structured dictionary representing
  545. their hierarchy. For example, considering the following project
  546. hierarchy::
  547. A
  548. |
  549. +-B-+
  550. | |
  551. C D
  552. If we query for project A subtree, the expected return is the following
  553. dictionary::
  554. 'subtree': {
  555. B['id']: {
  556. C['id']: None,
  557. D['id']: None
  558. }
  559. }
  560. """
  561. def _projects_indexed_by_parent(projects_list):
  562. projects_by_parent = {}
  563. for proj in projects_list:
  564. parent_id = proj.get('parent_id')
  565. if parent_id:
  566. if parent_id in projects_by_parent:
  567. projects_by_parent[parent_id].append(proj)
  568. else:
  569. projects_by_parent[parent_id] = [proj]
  570. return projects_by_parent
  571. subtree_list = self.list_projects_in_subtree(project_id)
  572. subtree_as_ids = self._build_subtree_as_ids_dict(
  573. project_id, _projects_indexed_by_parent(subtree_list))
  574. return subtree_as_ids
  575. def list_domains_from_ids(self, domain_ids):
  576. """List domains for the provided list of ids.
  577. :param domain_ids: list of ids
  578. :returns: a list of domain_refs.
  579. This method is used internally by the assignment manager to bulk read
  580. a set of domains given their ids.
  581. """
  582. # Retrieve the projects acting as domains get their correspondent
  583. # domains
  584. projects = self.list_projects_from_ids(domain_ids)
  585. domains = [self._get_domain_from_project(project)
  586. for project in projects]
  587. return domains
  588. @MEMOIZE
  589. def get_domain(self, domain_id):
  590. try:
  591. # Retrieve the corresponding project that acts as a domain
  592. project = self.driver.get_project(domain_id)
  593. # the DB backend might not operate in case sensitive mode,
  594. # therefore verify for exact match of IDs
  595. if domain_id != project['id']:
  596. raise exception.DomainNotFound(domain_id=domain_id)
  597. except exception.ProjectNotFound:
  598. raise exception.DomainNotFound(domain_id=domain_id)
  599. # Return its correspondent domain
  600. return self._get_domain_from_project(project)
  601. @MEMOIZE
  602. def get_domain_by_name(self, domain_name):
  603. try:
  604. # Retrieve the corresponding project that acts as a domain
  605. project = self.driver.get_project_by_name(domain_name,
  606. domain_id=None)
  607. except exception.ProjectNotFound:
  608. raise exception.DomainNotFound(domain_id=domain_name)
  609. # Return its correspondent domain
  610. return self._get_domain_from_project(project)
  611. def _get_domain_from_project(self, project_ref):
  612. """Create a domain ref from a project ref.
  613. Based on the provided project ref, create a domain ref, so that the
  614. result can be returned in response to a domain API call.
  615. """
  616. if not project_ref['is_domain']:
  617. LOG.error('Asked to convert a non-domain project into a '
  618. 'domain - Domain: %(domain_id)s, Project ID: '
  619. '%(id)s, Project Name: %(project_name)s',
  620. {'domain_id': project_ref['domain_id'],
  621. 'id': project_ref['id'],
  622. 'project_name': project_ref['name']})
  623. raise exception.DomainNotFound(domain_id=project_ref['id'])
  624. domain_ref = project_ref.copy()
  625. # As well as the project specific attributes that we need to remove,
  626. # there is an old compatibility issue in that update project (as well
  627. # as extracting an extra attributes), also includes a copy of the
  628. # actual extra dict as well - something that update domain does not do.
  629. for k in ['parent_id', 'domain_id', 'is_domain', 'extra']:
  630. domain_ref.pop(k, None)
  631. return domain_ref
  632. def create_domain(self, domain_id, domain, initiator=None):
  633. if (CONF.resource.domain_name_url_safe != 'off' and
  634. utils.is_not_url_safe(domain['name'])):
  635. self._raise_reserved_character_exception('Domain', domain['name'])
  636. project_from_domain = base.get_project_from_domain(domain)
  637. is_domain_project = self.create_project(
  638. domain_id, project_from_domain, initiator)
  639. return self._get_domain_from_project(is_domain_project)
  640. @manager.response_truncated
  641. def list_domains(self, hints=None):
  642. projects = self.list_projects_acting_as_domain(hints)
  643. domains = [self._get_domain_from_project(project)
  644. for project in projects]
  645. return domains
  646. def update_domain(self, domain_id, domain, initiator=None):
  647. # TODO(henry-nash): We shouldn't have to check for the federated domain
  648. # here as well as _update_project, but currently our tests assume the
  649. # checks are done in a specific order. The tests should be refactored.
  650. self.assert_domain_not_federated(domain_id, domain)
  651. project = base.get_project_from_domain(domain)
  652. try:
  653. original_domain = self.driver.get_project(domain_id)
  654. project = self._update_project(domain_id, project, initiator)
  655. except exception.ProjectNotFound:
  656. raise exception.DomainNotFound(domain_id=domain_id)
  657. domain_from_project = self._get_domain_from_project(project)
  658. self.get_domain.invalidate(self, domain_id)
  659. self.get_domain_by_name.invalidate(self, original_domain['name'])
  660. return domain_from_project
  661. def delete_domain(self, domain_id, initiator=None):
  662. # Use the driver directly to get the project that acts as a domain and
  663. # prevent using old cached value.
  664. try:
  665. domain = self.driver.get_project(domain_id)
  666. except exception.ProjectNotFound:
  667. raise exception.DomainNotFound(domain_id=domain_id)
  668. self._delete_domain(domain, initiator)
  669. def _delete_domain(self, domain, initiator=None):
  670. # To help avoid inadvertent deletes, we insist that the domain
  671. # has been previously disabled. This also prevents a user deleting
  672. # their own domain since, once it is disabled, they won't be able
  673. # to get a valid token to issue this delete.
  674. if domain['enabled']:
  675. raise exception.ForbiddenNotSecurity(
  676. _('Cannot delete a domain that is enabled, please disable it '
  677. 'first.'))
  678. domain_id = domain['id']
  679. self._delete_domain_contents(domain_id)
  680. notifications.Audit.internal(
  681. notifications.DOMAIN_DELETED, domain_id
  682. )
  683. self._delete_project(domain, initiator)
  684. try:
  685. self.get_domain.invalidate(self, domain_id)
  686. self.get_domain_by_name.invalidate(self, domain['name'])
  687. # Delete any database stored domain config
  688. PROVIDERS.domain_config_api.delete_config_options(domain_id)
  689. PROVIDERS.domain_config_api.release_registration(domain_id)
  690. finally:
  691. # attempt to send audit event even if the cache invalidation raises
  692. notifications.Audit.deleted(self._DOMAIN, domain_id, initiator)
  693. def _delete_domain_contents(self, domain_id):
  694. """Delete the contents of a domain.
  695. Before we delete a domain, we need to remove all the entities
  696. that are owned by it, i.e. Projects. To do this we
  697. call the delete function for these entities, which are
  698. themselves responsible for deleting any credentials and role grants
  699. associated with them as well as revoking any relevant tokens.
  700. """
  701. def _delete_projects(project, projects, examined):
  702. if project['id'] in examined:
  703. msg = ('Circular reference or a repeated entry found '
  704. 'projects hierarchy - %(project_id)s.')
  705. LOG.error(msg, {'project_id': project['id']})
  706. return
  707. examined.add(project['id'])
  708. children = [proj for proj in projects
  709. if proj.get('parent_id') == project['id']]
  710. for proj in children:
  711. _delete_projects(proj, projects, examined)
  712. try:
  713. self._delete_project(project, initiator=None)
  714. except exception.ProjectNotFound:
  715. LOG.debug(('Project %(projectid)s not found when '
  716. 'deleting domain contents for %(domainid)s, '
  717. 'continuing with cleanup.'),
  718. {'projectid': project['id'],
  719. 'domainid': domain_id})
  720. proj_refs = self.list_projects_in_domain(domain_id)
  721. # Deleting projects recursively
  722. roots = [x for x in proj_refs if x.get('parent_id') == domain_id]
  723. examined = set()
  724. for project in roots:
  725. _delete_projects(project, proj_refs, examined)
  726. @manager.response_truncated
  727. def list_projects(self, hints=None):
  728. if hints:
  729. tag_filters = {}
  730. # Handle project tag filters separately
  731. for f in list(hints.filters):
  732. if f['name'] in TAG_SEARCH_FILTERS:
  733. tag_filters[f['name']] = f['value']
  734. hints.filters.remove(f)
  735. if tag_filters:
  736. tag_refs = self.driver.list_projects_by_tags(tag_filters)
  737. project_refs = self.driver.list_projects(hints)
  738. ref_ids = [ref['id'] for ref in tag_refs]
  739. return [ref for ref in project_refs if ref['id'] in ref_ids]
  740. return self.driver.list_projects(hints or driver_hints.Hints())
  741. # NOTE(henry-nash): list_projects_in_domain is actually an internal method
  742. # and not exposed via the API. Therefore there is no need to support
  743. # driver hints for it.
  744. def list_projects_in_domain(self, domain_id):
  745. return self.driver.list_projects_in_domain(domain_id)
  746. def list_projects_acting_as_domain(self, hints=None):
  747. return self.driver.list_projects_acting_as_domain(
  748. hints or driver_hints.Hints())
  749. @MEMOIZE
  750. def get_project(self, project_id):
  751. return self.driver.get_project(project_id)
  752. @MEMOIZE
  753. def get_project_by_name(self, project_name, domain_id):
  754. return self.driver.get_project_by_name(project_name, domain_id)
  755. def _require_matching_domain_id(self, new_ref, orig_ref):
  756. """Ensure the current domain ID matches the reference one, if any.
  757. Provided we want domain IDs to be immutable, check whether any
  758. domain_id specified in the ref dictionary matches the existing
  759. domain_id for this entity.
  760. :param new_ref: the dictionary of new values proposed for this entity
  761. :param orig_ref: the dictionary of original values proposed for this
  762. entity
  763. :raises: :class:`keystone.exception.ValidationError`
  764. """
  765. if 'domain_id' in new_ref:
  766. if new_ref['domain_id'] != orig_ref['domain_id']:
  767. raise exception.ValidationError(_('Cannot change Domain ID'))
  768. def create_project_tag(self, project_id, tag, initiator=None):
  769. """Create a new tag on project.
  770. :param project_id: ID of a project to create a tag for
  771. :param tag: The string value of a tag to add
  772. :returns: The value of the created tag
  773. """
  774. project = self.driver.get_project(project_id)
  775. tag_name = tag.strip()
  776. project['tags'].append(tag_name)
  777. self.update_project(project_id, {'tags': project['tags']})
  778. notifications.Audit.created(
  779. self._PROJECT_TAG, tag_name, initiator)
  780. return tag_name
  781. def get_project_tag(self, project_id, tag_name):
  782. """Return information for a single tag on a project.
  783. :param project_id: ID of a project to retrive a tag from
  784. :param tag_name: Name of a tag to return
  785. :raises keystone.exception.ProjectTagNotFound: If the tag name
  786. does not exist on the project
  787. :returns: The tag value
  788. """
  789. project = self.driver.get_project(project_id)
  790. if tag_name not in project.get('tags'):
  791. raise exception.ProjectTagNotFound(project_tag=tag_name)
  792. return tag_name
  793. def list_project_tags(self, project_id):
  794. """List all tags on project.
  795. :param project_id: The ID of a project
  796. :returns: A list of tags from a project
  797. """
  798. project = self.driver.get_project(project_id)
  799. return project.get('tags', [])
  800. def update_project_tags(self, project_id, tags, initiator=None):
  801. """Update all tags on a project.
  802. :param project_id: The ID of the project to update
  803. :param tags: A list of tags to update on the project
  804. :returns: A list of tags
  805. """
  806. self.driver.get_project(project_id)
  807. tag_list = [t.strip() for t in tags]
  808. project = {'tags': tag_list}
  809. self.update_project(project_id, project)
  810. return tag_list
  811. def delete_project_tag(self, project_id, tag):
  812. """Delete single tag from project.
  813. :param project_id: The ID of the project
  814. :param tag: The tag value to delete
  815. :raises keystone.exception.ProjectTagNotFound: If the tag name
  816. does not exist on the project
  817. """
  818. project = self.driver.get_project(project_id)
  819. try:
  820. project['tags'].remove(tag)
  821. except ValueError:
  822. raise exception.ProjectTagNotFound(project_tag=tag)
  823. self.update_project(project_id, project)
  824. notifications.Audit.deleted(self._PROJECT_TAG, tag)
  825. def check_project_depth(self, max_depth=None):
  826. """Check project depth whether greater than input or not."""
  827. if max_depth:
  828. exceeded_project_ids = self.driver.check_project_depth(max_depth)
  829. if exceeded_project_ids:
  830. raise exception.LimitTreeExceedError(exceeded_project_ids,
  831. max_depth)
  832. MEMOIZE_CONFIG = cache.get_memoization_decorator(group='domain_config')
  833. class DomainConfigManager(manager.Manager):
  834. """Default pivot point for the Domain Config backend."""
  835. # NOTE(henry-nash): In order for a config option to be stored in the
  836. # standard table, it must be explicitly whitelisted. Options marked as
  837. # sensitive are stored in a separate table. Attempting to store options
  838. # that are not listed as either whitelisted or sensitive will raise an
  839. # exception.
  840. #
  841. # Only those options that affect the domain-specific driver support in
  842. # the identity manager are supported.
  843. driver_namespace = 'keystone.resource.domain_config'
  844. _provides_api = 'domain_config_api'
  845. # We explicitly state each whitelisted option instead of pulling all ldap
  846. # options from CONF and selectively pruning them to prevent a security
  847. # lapse. That way if a new ldap CONF key/value were to be added it wouldn't
  848. # automatically be added to the whitelisted options unless that is what was
  849. # intended. In which case, we explicitly add it to the list ourselves.
  850. whitelisted_options = {
  851. 'identity': ['driver', 'list_limit'],
  852. 'ldap': [
  853. 'url', 'user', 'suffix', 'query_scope', 'page_size',
  854. 'alias_dereferencing', 'debug_level', 'chase_referrals',
  855. 'user_tree_dn', 'user_filter', 'user_objectclass',
  856. 'user_id_attribute', 'user_name_attribute', 'user_mail_attribute',
  857. 'user_description_attribute', 'user_pass_attribute',
  858. 'user_enabled_attribute', 'user_enabled_invert',
  859. 'user_enabled_mask', 'user_enabled_default',
  860. 'user_attribute_ignore', 'user_default_project_id_attribute',
  861. 'user_enabled_emulation', 'user_enabled_emulation_dn',
  862. 'user_enabled_emulation_use_group_config',
  863. 'user_additional_attribute_mapping', 'group_tree_dn',
  864. 'group_filter', 'group_objectclass', 'group_id_attribute',
  865. 'group_name_attribute', 'group_members_are_ids',
  866. 'group_member_attribute', 'group_desc_attribute',
  867. 'group_attribute_ignore', 'group_additional_attribute_mapping',
  868. 'tls_cacertfile', 'tls_cacertdir', 'use_tls', 'tls_req_cert',
  869. 'use_pool', 'pool_size', 'pool_retry_max', 'pool_retry_delay',
  870. 'pool_connection_timeout', 'pool_connection_lifetime',
  871. 'use_auth_pool', 'auth_pool_size', 'auth_pool_connection_lifetime'
  872. ]
  873. }
  874. sensitive_options = {
  875. 'identity': [],
  876. 'ldap': ['password']
  877. }
  878. def __init__(self):
  879. super(DomainConfigManager, self).__init__(CONF.domain_config.driver)
  880. def _assert_valid_config(self, config):
  881. """Ensure the options in the config are valid.
  882. This method is called to validate the request config in create and
  883. update manager calls.
  884. :param config: config structure being created or updated
  885. """
  886. # Something must be defined in the request
  887. if not config:
  888. raise exception.InvalidDomainConfig(
  889. reason=_('No options specified'))
  890. # Make sure the groups/options defined in config itself are valid
  891. for group in config:
  892. if (not config[group] or not
  893. isinstance(config[group], dict)):
  894. msg = _('The value of group %(group)s specified in the '
  895. 'config should be a dictionary of options') % {
  896. 'group': group}
  897. raise exception.InvalidDomainConfig(reason=msg)
  898. for option in config[group]:
  899. self._assert_valid_group_and_option(group, option)
  900. def _assert_valid_group_and_option(self, group, option):
  901. """Ensure the combination of group and option is valid.
  902. :param group: optional group name, if specified it must be one
  903. we support
  904. :param option: optional option name, if specified it must be one
  905. we support and a group must also be specified
  906. """
  907. if not group and not option:
  908. # For all calls, it's OK for neither to be defined, it means you
  909. # are operating on all config options for that domain.
  910. return
  911. if not group and option:
  912. # Our API structure should prevent this from ever happening, so if
  913. # it does, then this is coding error.
  914. msg = _('Option %(option)s found with no group specified while '
  915. 'checking domain configuration request') % {
  916. 'option': option}
  917. raise exception.UnexpectedError(exception=msg)
  918. if (group and group not in self.whitelisted_options and
  919. group not in self.sensitive_options):
  920. msg = _('Group %(group)s is not supported '
  921. 'for domain specific configurations') % {'group': group}
  922. raise exception.InvalidDomainConfig(reason=msg)
  923. if option:
  924. if (option not in self.whitelisted_options[group] and option not in
  925. self.sensitive_options[group]):
  926. msg = _('Option %(option)s in group %(group)s is not '
  927. 'supported for domain specific configurations') % {
  928. 'group': group, 'option': option}
  929. raise exception.InvalidDomainConfig(reason=msg)
  930. def _is_sensitive(self, group, option):
  931. return option in self.sensitive_options[group]
  932. def _config_to_list(self, config):
  933. """Build list of options for use by backend drivers."""
  934. option_list = []
  935. for group in config:
  936. for option in config[group]:
  937. option_list.append({
  938. 'group': group, 'option': option,
  939. 'value': config[group][option],
  940. 'sensitive': self._is_sensitive(group, option)})
  941. return option_list
  942. def _option_dict(self, group, option):
  943. group_attr = getattr(CONF, group)
  944. return {'group': group, 'option': option,
  945. 'value': getattr(group_attr, option)}
  946. def _list_to_config(self, whitelisted, sensitive=None, req_option=None):
  947. """Build config dict from a list of option dicts.
  948. :param whitelisted: list of dicts containing options and their groups,
  949. this has already been filtered to only contain
  950. those options to include in the output.
  951. :param sensitive: list of dicts containing sensitive options and their
  952. groups, this has already been filtered to only
  953. contain those options to include in the output.
  954. :param req_option: the individual option requested
  955. :returns: a config dict, including sensitive if specified
  956. """
  957. the_list = whitelisted + (sensitive or [])
  958. if not the_list:
  959. return {}
  960. if req_option:
  961. # The request was specific to an individual option, so
  962. # no need to include the group in the output. We first check that
  963. # there is only one option in the answer (and that it's the right
  964. # one) - if not, something has gone wrong and we raise an error
  965. if len(the_list) > 1 or the_list[0]['option'] != req_option:
  966. LOG.error('Unexpected results in response for domain '
  967. 'config - %(count)s responses, first option is '
  968. '%(option)s, expected option %(expected)s',
  969. {'count': len(the_list), 'option': list[0]['option'],
  970. 'expected': req_option})
  971. raise exception.UnexpectedError(
  972. _('An unexpected error occurred when retrieving domain '
  973. 'configs'))
  974. return {the_list[0]['option']: the_list[0]['value']}
  975. config = {}
  976. for option in the_list:
  977. config.setdefault(option['group'], {})
  978. config[option['group']][option['option']] = option['value']
  979. return config
  980. def create_config(self, domain_id, config):
  981. """Create config for a domain.
  982. :param domain_id: the domain in question
  983. :param config: the dict of config groups/options to assign to the
  984. domain
  985. Creates a new config, overwriting any previous config (no Conflict
  986. error will be generated).
  987. :returns: a dict of group dicts containing the options, with any that
  988. are sensitive removed
  989. :raises keystone.exception.InvalidDomainConfig: when the config
  990. contains options we do not support
  991. """
  992. self._assert_valid_config(config)
  993. option_list = self._config_to_list(config)
  994. self.create_config_options(domain_id, option_list)
  995. # Since we are caching on the full substituted config, we just
  996. # invalidate here, rather than try and create the right result to
  997. # cache.
  998. self.get_config_with_sensitive_info.invalidate(self, domain_id)
  999. return self._list_to_config(self.list_config_options(domain_id))
  1000. def get_config(self, domain_id, group=None, option=None):
  1001. """Get config, or partial config, for a domain.
  1002. :param domain_id: the domain in question
  1003. :param group: an optional specific group of options
  1004. :param option: an optional specific option within the group
  1005. :returns: a dict of group dicts containing the whitelisted options,
  1006. filtered by group and option specified
  1007. :raises keystone.exception.DomainConfigNotFound: when no config found
  1008. that matches domain_id, group and option specified
  1009. :raises keystone.exception.InvalidDomainConfig: when the config
  1010. and group/option parameters specify an option we do not
  1011. support
  1012. An example response::
  1013. {
  1014. 'ldap': {
  1015. 'url': 'myurl'
  1016. 'user_tree_dn': 'OU=myou'},
  1017. 'identity': {
  1018. 'driver': 'ldap'}
  1019. }
  1020. """
  1021. self._assert_valid_group_and_option(group, option)
  1022. whitelisted = self.list_config_options(domain_id, group, option)
  1023. if whitelisted:
  1024. return self._list_to_config(whitelisted, req_option=option)
  1025. if option:
  1026. msg = _('option %(option)s in group %(group)s') % {
  1027. 'group': group, 'option': option}
  1028. elif group:
  1029. msg = _('group %(group)s') % {'group': group}
  1030. else:
  1031. msg = _('any options')
  1032. raise exception.DomainConfigNotFound(
  1033. domain_id=domain_id, group_or_option=msg)
  1034. def get_security_compliance_config(self, domain_id, group, option=None):
  1035. r"""Get full or partial security compliance config from configuration.
  1036. :param domain_id: the domain in question
  1037. :param group: a specific group of options
  1038. :param option: an optional specific option within the group
  1039. :returns: a dict of group dicts containing the whitelisted options,
  1040. filtered by group and option specified
  1041. :raises keystone.exception.InvalidDomainConfig: when the config
  1042. and group/option parameters specify an option we do not
  1043. support
  1044. An example response::
  1045. {
  1046. 'security_compliance': {
  1047. 'password_regex': '^(?=.*\d)(?=.*[a-zA-Z]).{7,}$'
  1048. 'password_regex_description':
  1049. 'A password must consist of at least 1 letter, '
  1050. '1 digit, and have a minimum length of 7 characters'
  1051. }
  1052. }
  1053. """
  1054. if domain_id != CONF.identity.default_domain_id:
  1055. msg = _('Reading security compliance information for any domain '
  1056. 'other than the default domain is not allowed or '
  1057. 'supported.')
  1058. raise exception.InvalidDomainConfig(reason=msg)
  1059. config_list = []
  1060. readable_options = ['password_regex', 'password_regex_description']
  1061. if option and option not in readable_options:
  1062. msg = _('Reading security compliance values other than '
  1063. 'password_regex and password_regex_description is not '
  1064. 'allowed.')
  1065. raise exception.InvalidDomainConfig(reason=msg)
  1066. elif option and option in readable_options:
  1067. config_list.append(self._option_dict(group, option))
  1068. elif not option:
  1069. for op in readable_options:
  1070. config_list.append(self._option_dict(group, op))
  1071. # We already validated that the group is the security_compliance group
  1072. # so we can move along and start validating the options
  1073. return self._list_to_config(config_list, req_option=option)
  1074. def update_config(self, domain_id, config, group=None, option=None):
  1075. """Update config, or partial config, for a domain.
  1076. :param domain_id: the domain in question
  1077. :param config: the config dict containing and groups/options being
  1078. updated
  1079. :param group: an optional specific group of options, which if specified
  1080. must appear in config, with no other groups
  1081. :param option: an optional specific option within the group, which if
  1082. specified must appear in config, with no other options
  1083. The contents of the supplied config will be merged with the existing
  1084. config for this domain, updating or creating new options if these did
  1085. not previously exist. If group or option is specified, then the update
  1086. will be limited to those specified items and the inclusion of other
  1087. options in the supplied config will raise an exception, as will the
  1088. situation when those options do not already exist in the current
  1089. config.
  1090. :returns: a dict of groups containing all whitelisted options
  1091. :raises keystone.exception.InvalidDomainConfig: when the config
  1092. and group/option parameters specify an option we do not
  1093. support or one that does not exist in the original config
  1094. """
  1095. def _assert_valid_update(domain_id, config, group=None, option=None):
  1096. """Ensure the combination of config, group and option is valid."""
  1097. self._assert_valid_config(config)
  1098. self._assert_valid_group_and_option(group, option)
  1099. # If a group has been specified, then the request is to
  1100. # explicitly only update the options in that group - so the config
  1101. # must not contain anything else. Further, that group must exist in
  1102. # the original config. Likewise, if an option has been specified,
  1103. # then the group in the config must only contain that option and it
  1104. # also must exist in the original config.
  1105. if group:
  1106. if len(config) != 1 or (option and len(config[group]) != 1):
  1107. if option:
  1108. msg = _('Trying to update option %(option)s in group '
  1109. '%(group)s, so that, and only that, option '
  1110. 'must be specified in the config') % {
  1111. 'group': group, 'option': option}
  1112. else:
  1113. msg = _('Trying to update group %(group)s, so that, '
  1114. 'and only that, group must be specified in '
  1115. 'the config') % {'group': group}
  1116. raise exception.InvalidDomainConfig(reason=msg)
  1117. # So we now know we have the right number of entries in the
  1118. # config that align with a group/option being specified, but we
  1119. # must also make sure they match.
  1120. if group not in config:
  1121. msg = _('request to update group %(group)s, but config '
  1122. 'provided contains group %(group_other)s '
  1123. 'instead') % {
  1124. 'group': group,
  1125. 'group_other': list(config.keys())[0]}
  1126. raise exception.InvalidDomainConfig(reason=msg)
  1127. if option and option not in config[group]:
  1128. msg = _('Trying to update option %(option)s in group '
  1129. '%(group)s, but config provided contains option '
  1130. '%(option_other)s instead') % {
  1131. 'group': group, 'option': option,
  1132. 'option_other': list(config[group].keys())[0]}
  1133. raise exception.InvalidDomainConfig(reason=msg)
  1134. # Finally, we need to check if the group/option specified
  1135. # already exists in the original config - since if not, to keep
  1136. # with the semantics of an update, we need to fail with
  1137. # a DomainConfigNotFound
  1138. if not self._get_config_with_sensitive_info(domain_id,
  1139. group, option):
  1140. if option:
  1141. msg = _('option %(option)s in group %(group)s') % {
  1142. 'group': group, 'option': option}
  1143. raise exception.DomainConfigNotFound(
  1144. domain_id=domain_id, group_or_option=msg)
  1145. else:
  1146. msg = _('group %(group)s') % {'group': group}
  1147. raise exception.DomainConfigNotFound(
  1148. domain_id=domain_id, group_or_option=msg)
  1149. update_config = config
  1150. if group and option:
  1151. # The config will just be a dict containing the option and
  1152. # its value, so make it look like a single option under the
  1153. # group in question
  1154. update_config = {group: config}
  1155. _assert_valid_update(domain_id, update_config, group, option)
  1156. option_list = self._config_to_list(update_config)
  1157. self.update_config_options(domain_id, option_list)
  1158. self.get_config_with_sensitive_info.invalidate(self, domain_id)
  1159. return self.get_config(domain_id)
  1160. def delete_config(self, domain_id, group=None, option=None):
  1161. """Delete config, or partial config, for the domain.
  1162. :param domain_id: the domain in question
  1163. :param group: an optional specific group of options
  1164. :param option: an optional specific option within the group
  1165. If group and option are None, then the entire config for the domain
  1166. is deleted. If group is not None, then just that group of options will
  1167. be deleted. If group and option are both specified, then just that
  1168. option is deleted.
  1169. :raises keystone.exception.InvalidDomainConfig: when group/option
  1170. parameters specify an option we do not support or one that
  1171. does not exist in the original config.
  1172. """
  1173. self._assert_valid_group_and_option(group, option)
  1174. if group:
  1175. # As this is a partial delete, then make sure the items requested
  1176. # are valid and exist in the current config
  1177. current_config = self._get_config_with_sensitive_info(domain_id)
  1178. # Raise an exception if the group/options specified don't exist in
  1179. # the current config so that the delete method provides the
  1180. # correct error semantics.
  1181. current_group = current_config.get(group)
  1182. if not current_group:
  1183. msg = _('group %(group)s') % {'group': group}
  1184. raise exception.DomainConfigNotFound(
  1185. domain_id=domain_id, group_or_option=msg)
  1186. if option and not current_group.get(option):
  1187. msg = _('option %(option)s in group %(group)s') % {
  1188. 'group': group, 'option': option}
  1189. raise exception.DomainConfigNotFound(
  1190. domain_id=domain_id, group_or_option=msg)
  1191. self.delete_config_options(domain_id, group, option)
  1192. self.get_config_with_sensitive_info.invalidate(self, domain_id)
  1193. def _get_config_with_sensitive_info(self, domain_id, group=None,
  1194. option=None):
  1195. """Get config for a domain/group/option with sensitive info included.
  1196. This is only used by the methods within this class, which may need to
  1197. check individual groups or options.
  1198. """
  1199. whitelisted = self.list_config_options(domain_id, group, option)
  1200. sensitive = self.list_config_options(domain_id, group, option,
  1201. sensitive=True)
  1202. # Check if there are any sensitive substitutions needed. We first try
  1203. # and simply ensure any sensitive options that have valid substitution
  1204. # references in the whitelisted options are substituted. We then check
  1205. # the resulting whitelisted option and raise a warning if there
  1206. # appears to be an unmatched or incorrectly constructed substitution
  1207. # reference. To avoid the risk of logging any sensitive options that
  1208. # have already been substituted, we first take a copy of the
  1209. # whitelisted option.
  1210. # Build a dict of the sensitive options ready to try substitution
  1211. sensitive_dict = {s['option']: s['value'] for s in sensitive}
  1212. for each_whitelisted in whitelisted:
  1213. if not isinstance(each_whitelisted['value'], six.string_types):
  1214. # We only support substitutions into string types, if its an
  1215. # integer, list etc. then just continue onto the next one
  1216. continue
  1217. # Store away the original value in case we need to raise a warning
  1218. # after substitution.
  1219. original_value = each_whitelisted['value']
  1220. warning_msg = ''
  1221. try:
  1222. each_whitelisted['value'] = (
  1223. each_whitelisted['value'] % sensitive_dict)
  1224. except KeyError:
  1225. warning_msg = (
  1226. 'Found what looks like an unmatched config option '
  1227. 'substitution reference - domain: %(domain)s, group: '
  1228. '%(group)s, option: %(option)s, value: %(value)s. Perhaps '
  1229. 'the config option to which it refers has yet to be '
  1230. 'added?')
  1231. except (ValueError, TypeError):
  1232. warning_msg = (
  1233. 'Found what looks like an incorrectly constructed '
  1234. 'config option substitution reference - domain: '
  1235. '%(domain)s, group: %(group)s, option: %(option)s, '
  1236. 'value: %(value)s.')
  1237. if warning_msg:
  1238. LOG.warning(warning_msg, {
  1239. 'domain': domain_id,
  1240. 'group': each_whitelisted['group'],
  1241. 'option': each_whitelisted['option'],
  1242. 'value': original_value})
  1243. return self._list_to_config(whitelisted, sensitive)
  1244. @MEMOIZE_CONFIG
  1245. def get_config_with_sensitive_info(self, domain_id):
  1246. """Get config for a domain with sensitive info included.
  1247. This method is not exposed via the public API, but is used by the
  1248. identity manager to initialize a domain with the fully formed config
  1249. options.
  1250. """
  1251. return self._get_config_with_sensitive_info(domain_id)
  1252. def get_config_default(self, group=None, option=None):
  1253. """Get default config, or partial default config.
  1254. :param group: an optional specific group of options
  1255. :param option: an optional specific option within the group
  1256. :returns: a dict of group dicts containing the default options,
  1257. filtered by group and option if specified
  1258. :raises keystone.exception.InvalidDomainConfig: when the config
  1259. and group/option parameters specify an option we do not
  1260. support (or one that is not whitelisted).
  1261. An example response::
  1262. {
  1263. 'ldap': {
  1264. 'url': 'myurl',
  1265. 'user_tree_dn': 'OU=myou',
  1266. ....},
  1267. 'identity': {
  1268. 'driver': 'ldap'}
  1269. }
  1270. """
  1271. self._assert_valid_group_and_option(group, option)
  1272. config_list = []
  1273. if group:
  1274. if option:
  1275. if option not in self.whitelisted_options[group]:
  1276. msg = _('Reading the default for option %(option)s in '
  1277. 'group %(group)s is not supported') % {
  1278. 'option': option, 'group': group}
  1279. raise exception.InvalidDomainConfig(reason=msg)
  1280. config_list.append(self._option_dict(group, option))
  1281. else:
  1282. for each_option in self.whitelisted_options[group]:
  1283. config_list.append(self._option_dict(group, each_option))
  1284. else:
  1285. for each_group in self.whitelisted_options:
  1286. for each_option in self.whitelisted_options[each_group]:
  1287. config_list.append(
  1288. self._option_dict(each_group, each_option)
  1289. )
  1290. return self._list_to_config(config_list, req_option=option)