git_auth.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2015-2017 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import absolute_import, print_function, unicode_literals
  8. import abc
  9. import json
  10. import logging
  11. import os
  12. import subprocess
  13. import tempfile
  14. from io import open
  15. import pkg_resources
  16. import werkzeug.utils
  17. from six import with_metaclass
  18. from six.moves import dbm_gnu
  19. import pagure.exceptions
  20. import pagure.lib.model_base
  21. import pagure.lib.query
  22. from pagure.config import config as pagure_config
  23. from pagure.lib import model
  24. from pagure.utils import is_repo_collaborator, lookup_deploykey
  25. # logging.config.dictConfig(pagure_config.get('LOGGING') or {'version': 1})
  26. _log = logging.getLogger(__name__)
  27. GIT_AUTH_BACKEND_NAME = None
  28. GIT_AUTH_BACKEND_INSTANCE = None
  29. def get_git_auth_helper(backend=None):
  30. """Instantiate and return the appropriate git auth helper backend.
  31. :arg backend: The name of the backend to find on the system (declared via
  32. the entry_points in setup.py).
  33. Pagure comes by default with the following backends:
  34. test_auth, gitolite2, gitolite3
  35. :type backend: str
  36. """
  37. global GIT_AUTH_BACKEND_NAME
  38. global GIT_AUTH_BACKEND_INSTANCE
  39. if backend is None:
  40. backend = pagure_config["GIT_AUTH_BACKEND"]
  41. if (
  42. GIT_AUTH_BACKEND_NAME
  43. and GIT_AUTH_BACKEND_INSTANCE
  44. and backend == GIT_AUTH_BACKEND_NAME
  45. ):
  46. # This got previously instantiated, return that instance to avoid
  47. # having to instantiate it multiple times as long as the same backend
  48. # is used.
  49. return GIT_AUTH_BACKEND_INSTANCE
  50. _log.info("Looking for backend: %s", backend)
  51. points = pkg_resources.iter_entry_points("pagure.git_auth.helpers")
  52. classes = dict([(point.name, point) for point in points])
  53. _log.debug("Found the following installed helpers %r" % classes)
  54. if len(classes) == 0:
  55. _log.debug("Was unable to find any helpers, registering built-in")
  56. cls = {
  57. "test_auth": GitAuthTestHelper,
  58. "gitolite2": Gitolite2Auth,
  59. "gitolite3": Gitolite3Auth,
  60. "pagure": PagureGitAuth,
  61. "pagure_authorized_keys": PagureGitAuth,
  62. }[backend]
  63. else:
  64. cls = classes[backend].load()
  65. _log.debug("Returning helper %r from backend key %r" % (cls, backend))
  66. GIT_AUTH_BACKEND_NAME = backend
  67. GIT_AUTH_BACKEND_INSTANCE = cls()
  68. return GIT_AUTH_BACKEND_INSTANCE
  69. class GitAuthHelper(with_metaclass(abc.ABCMeta, object)):
  70. """The class to inherit from when creating your own git authentication
  71. helper.
  72. """
  73. is_dynamic = False
  74. @classmethod
  75. @abc.abstractmethod
  76. def generate_acls(self, project, group=None):
  77. """This is the method that is called by pagure to generate the
  78. configuration file.
  79. :arg project: the project of which to update the ACLs. This argument
  80. can take three values: ``-1``, ``None`` and a project.
  81. If project is ``-1``, the configuration should be refreshed for
  82. *all* projects.
  83. If project is ``None``, there no specific project to refresh
  84. but the ssh key of an user was added and updated or a group
  85. was removed.
  86. If project is a pagure.lib.model.Project, the configuration of
  87. this project should be updated.
  88. :type project: None, int or pagure.lib.model.Project
  89. :kwarg group: the group to refresh the members of
  90. :type group: None or pagure.lib.model.PagureGroup
  91. (This behaviour is based on the workflow of gitolite, if you are
  92. implementing a different auth backend and need more granularity,
  93. feel free to let us know.)
  94. """
  95. pass
  96. @classmethod
  97. @abc.abstractmethod
  98. def remove_acls(self, session, project):
  99. """This is the method that is called by pagure to remove a project
  100. from the configuration file.
  101. :arg cls: the current class
  102. :type: GitAuthHelper
  103. :arg session: the session with which to connect to the database
  104. :arg project: the project to remove from the gitolite configuration
  105. file.
  106. :type project: pagure.lib.model.Project
  107. """
  108. pass
  109. @classmethod
  110. # This method can't be marked as abstract, since it's new and that would
  111. # break backwards compatibility
  112. def check_acl(cls, session, project, username, refname, **info):
  113. """This method is used in Dynamic Git Auth helpers to check acls.
  114. It is acceptable for implementations to print things, which will be
  115. returned to the user.
  116. Please make sure to add a **kwarg in any implementation, even if
  117. specific keyword arguments are added for the known fields, to make
  118. sure your implementation remains working if new items are added.
  119. Args:
  120. session (sqlalchemy.Session): Database session
  121. project (model.Project): Project instance push is for
  122. username (string): The name of the user trying to push
  123. refname (string): The name of the ref being pushed to
  124. Kwargs:
  125. Extra arguments to help in deciding whether to approve or deny a
  126. push. This may get additional possible values later on, but will
  127. have at least:
  128. - is_update (bool): Whether this is being run at the "update" hook
  129. moment. See the return type notes to see the differences.
  130. - revfrom (string): The commit hash the update is happening from.
  131. - revto (string): The commit hash the update is happening to.
  132. - pull_request (model.PullRequest or None): The PR that is trying
  133. to be merged.
  134. - repotype (string): The pagure.lib.query.get_repotypes() value
  135. for the repo being pushed to.
  136. - repodir (string): A directory containing the current
  137. repository, including the new objects to be approved.
  138. Note that this might or might not be directly writable, and any
  139. writes might or might not be accepted. ACL checks MUST not make
  140. any changes in this repository. (added after 5.0.1)
  141. Returns (bool): Whether to allow this push.
  142. If is_update is False and the ACL returns False, the entire push
  143. is aborted. If is_update is True and the ACL returns True, only
  144. a single ref update is blocked. So if you want to block just a
  145. single ref from being updated, only return False if is_update
  146. is True.
  147. """
  148. raise NotImplementedError(
  149. "check_acl on static Git Auth Backend called"
  150. )
  151. def _read_file(filename):
  152. """Reads the specified file and return its content.
  153. Returns None if it could not read the file for any reason.
  154. """
  155. if not os.path.exists(filename):
  156. _log.info("Could not find file: %s", filename)
  157. else:
  158. with open(filename) as stream:
  159. return stream.read()
  160. class Gitolite2Auth(GitAuthHelper):
  161. """A gitolite 2 authentication module."""
  162. @classmethod
  163. def _process_project(cls, project, config, global_pr_only):
  164. """Generate the gitolite configuration for the specified project.
  165. :arg project: the project to generate the configuration for
  166. :type project: pagure.lib.model.Project
  167. :arg config: a list containing the different lines of the
  168. configuration file
  169. :type config: list
  170. :arg groups: a dictionary containing the group name as key and the
  171. users member of the group as values
  172. :type groups: dict(str: list)
  173. :arg global_pr_only: boolean on whether the pagure instance enforces
  174. the PR workflow only or not
  175. :type global_pr_only: bool
  176. :return: the updated config
  177. :return type: list
  178. """
  179. _log.debug(" Processing project: %s", project.fullname)
  180. # Check if the project or the pagure instance enforce the PR only
  181. # development model.
  182. pr_only = project.settings.get("pull_request_access_only", False)
  183. repos_to_create = ["repos"]
  184. if pagure_config.get("ENABLE_DOCS", True):
  185. repos_to_create.append("docs/")
  186. if pagure_config.get("ENABLE_TICKETS", True):
  187. repos_to_create.append("tickets/")
  188. # no setting yet to disable pull-requests
  189. repos_to_create.append("requests/")
  190. for repos in repos_to_create:
  191. if repos == "repos":
  192. # Do not grant access to project enforcing the PR model
  193. if pr_only or (global_pr_only and not project.is_fork):
  194. continue
  195. repos = ""
  196. config.append("repo %s%s" % (repos, project.fullname))
  197. if not project.private and repos not in ["tickets/", "requests/"]:
  198. config.append(" R = @all")
  199. if project.committer_groups:
  200. config.append(
  201. " RW+ = @%s"
  202. % " @".join(
  203. [
  204. group.group_name
  205. for group in project.committer_groups
  206. ]
  207. )
  208. )
  209. config.append(" RW+ = %s" % project.user.user)
  210. for user in project.committers:
  211. # This should never be the case (that the project.user
  212. # is in the committers) but better safe than sorry
  213. if user.user != project.user.user:
  214. config.append(" RW+ = %s" % user.user)
  215. for deploykey in project.deploykeys:
  216. access = "R"
  217. if deploykey.pushaccess:
  218. access = "RW+"
  219. # Note: the replace of / with _ is because gitolite
  220. # users can't contain a /. At first, this might look
  221. # like deploy keys in a project called
  222. # $namespace_$project would give access to the repos of
  223. # a project $namespace/$project or vica versa, however
  224. # this is NOT the case because we add the deploykey.id
  225. # to the end of the deploykey name, which means it is
  226. # unique. The project name is solely there to make it
  227. # easier to determine what project created the deploykey
  228. # for admins.
  229. config.append(
  230. " %s = deploykey_%s_%s"
  231. % (
  232. access,
  233. werkzeug.utils.secure_filename(project.fullname),
  234. deploykey.id,
  235. )
  236. )
  237. config.append("")
  238. return config
  239. @classmethod
  240. def _clean_current_config(cls, current_config, project):
  241. """Remove the specified project from the current configuration file
  242. :arg current_config: the content of the current/actual gitolite
  243. configuration file read from the disk
  244. :type current_config: list
  245. :arg project: the project to update in the configuration file
  246. :type project: pagure.lib.model.Project
  247. """
  248. keys = [
  249. "repo %s%s" % (repos, project.fullname)
  250. for repos in ["", "docs/", "tickets/", "requests/"]
  251. ]
  252. keep = True
  253. config = []
  254. for line in current_config:
  255. line = line.rstrip()
  256. if line in keys:
  257. keep = False
  258. continue
  259. if keep is False and line == "":
  260. keep = True
  261. if keep:
  262. config.append(line)
  263. return config
  264. @classmethod
  265. def _clean_groups(cls, config, group=None):
  266. """Removes the groups in the given configuration file.
  267. :arg config: the current configuration
  268. :type config: list
  269. :kwarg group: the group to refresh the members of
  270. :type group: None or pagure.lib.model.PagureGroup
  271. :return: the configuration without the groups
  272. :return type: list
  273. """
  274. if group is None:
  275. output = [
  276. row.rstrip()
  277. for row in config
  278. if not row.startswith("@") and row.strip() != "# end of groups"
  279. ]
  280. else:
  281. end_grp = None
  282. seen = False
  283. output = []
  284. for idx, row in enumerate(config):
  285. if end_grp is None and row.startswith("repo "):
  286. end_grp = idx
  287. if row.startswith("@%s " % group.group_name):
  288. seen = True
  289. row = "@%s = %s" % (
  290. group.group_name,
  291. " ".join(
  292. sorted([user.username for user in group.users])
  293. ),
  294. )
  295. output.append(row)
  296. if not seen:
  297. row = "@%s = %s" % (
  298. group.group_name,
  299. " ".join(sorted([user.username for user in group.users])),
  300. )
  301. output.insert(end_grp, "")
  302. output.insert(end_grp, row)
  303. return output
  304. @classmethod
  305. def _generate_groups_config(cls, session):
  306. """Generate the gitolite configuration for all of the groups.
  307. :arg session: the session with which to connect to the database
  308. :return: the gitolite configuration for the groups
  309. :return type: list
  310. """
  311. query = session.query(model.PagureGroup).order_by(
  312. model.PagureGroup.group_name
  313. )
  314. groups = {}
  315. for grp in query.all():
  316. groups[grp.group_name] = [user.username for user in grp.users]
  317. return groups
  318. @classmethod
  319. def _get_current_config(cls, configfile, preconfig=None, postconfig=None):
  320. """Load the current gitolite configuration file from the disk.
  321. :arg configfile: the name of the configuration file to load
  322. :type configfile: str
  323. :kwarg preconf: the content of the file to include at the top of the
  324. gitolite configuration file, used here to determine that a part of
  325. the configuration file should be cleaned at the top.
  326. :type preconf: None or str
  327. :kwarg postconf: the content of the file to include at the bottom of
  328. the gitolite configuration file, used here to determine that a part
  329. of the configuration file should be cleaned at the bottom.
  330. :type postconf: None or str
  331. """
  332. _log.info("Reading in the current configuration: %s", configfile)
  333. with open(configfile) as stream:
  334. current_config = [line.rstrip() for line in stream]
  335. if current_config and current_config[-1] == "# end of body":
  336. current_config = current_config[:-1]
  337. if preconfig:
  338. idx = None
  339. for idx, row in enumerate(current_config):
  340. if row.strip() == "# end of header":
  341. break
  342. if idx is not None:
  343. idx = idx + 1
  344. _log.info("Removing the first %s lines", idx)
  345. current_config = current_config[idx:]
  346. if postconfig:
  347. idx = None
  348. for idx, row in enumerate(current_config):
  349. if row.strip() == "# end of body":
  350. break
  351. if idx is not None:
  352. _log.info(
  353. "Keeping the first %s lines out of %s",
  354. idx,
  355. len(current_config),
  356. )
  357. current_config = current_config[:idx]
  358. return current_config
  359. @classmethod
  360. def write_gitolite_acls(
  361. cls,
  362. session,
  363. configfile,
  364. project,
  365. preconf=None,
  366. postconf=None,
  367. group=None,
  368. ):
  369. """Generate the configuration file for gitolite for all projects
  370. on the forge.
  371. :arg cls: the current class
  372. :type: Gitolite2Auth
  373. :arg session: a session to connect to the database with
  374. :arg configfile: the name of the configuration file to generate/write
  375. :type configfile: str
  376. :arg project: the project to update in the gitolite configuration
  377. file. It can be of three types/values.
  378. If it is ``-1`` or if the file does not exist on disk, the
  379. entire gitolite configuration will be re-generated.
  380. If it is ``None``, the gitolite configuration will have its
  381. groups information updated but not the projects and will be
  382. re-compiled.
  383. If it is a ``pagure.lib.model.Project``, the gitolite
  384. configuration will be updated for just this project.
  385. :type project: None, int or spagure.lib.model.Project
  386. :kwarg preconf: a file to include at the top of the configuration
  387. file
  388. :type preconf: None or str
  389. :kwarg postconf: a file to include at the bottom of the
  390. configuration file
  391. :type postconf: None or str
  392. :kwarg group: the group to refresh the members of
  393. :type group: None or pagure.lib.model.PagureGroup
  394. """
  395. _log.info("Write down the gitolite configuration file")
  396. preconfig = None
  397. if preconf:
  398. _log.info(
  399. "Loading the file to include at the top of the generated one"
  400. )
  401. preconfig = _read_file(preconf)
  402. postconfig = None
  403. if postconf:
  404. _log.info(
  405. "Loading the file to include at the end of the generated one"
  406. )
  407. postconfig = _read_file(postconf)
  408. global_pr_only = pagure_config.get("PR_ONLY", False)
  409. config = []
  410. groups = {}
  411. if group is None:
  412. groups = cls._generate_groups_config(session)
  413. if project == -1 or not os.path.exists(configfile):
  414. _log.info("Refreshing the configuration for all projects")
  415. query = session.query(model.Project).order_by(model.Project.id)
  416. for project in query.all():
  417. config = cls._process_project(project, config, global_pr_only)
  418. elif project:
  419. _log.info("Refreshing the configuration for one project")
  420. config = cls._process_project(project, config, global_pr_only)
  421. current_config = cls._get_current_config(
  422. configfile, preconfig, postconfig
  423. )
  424. current_config = cls._clean_current_config(current_config, project)
  425. config = current_config + config
  426. if config:
  427. _log.info("Cleaning the group %s from the loaded config", group)
  428. config = cls._clean_groups(config, group=group)
  429. else:
  430. current_config = cls._get_current_config(
  431. configfile, preconfig, postconfig
  432. )
  433. _log.info("Cleaning the group %s from the config on disk", group)
  434. config = cls._clean_groups(current_config, group=group)
  435. if not config:
  436. return
  437. _log.info("Writing the configuration to: %s", configfile)
  438. with open(configfile, "w", encoding="utf-8") as stream:
  439. if preconfig:
  440. stream.write(preconfig + "\n")
  441. stream.write("# end of header\n")
  442. if groups:
  443. for key in sorted(groups):
  444. stream.write("@%s = %s\n" % (key, " ".join(groups[key])))
  445. stream.write("# end of groups\n\n")
  446. prev = None
  447. for row in config:
  448. if prev is None:
  449. prev = row
  450. if prev == row == "":
  451. continue
  452. stream.write(row + "\n")
  453. prev = row
  454. stream.write("# end of body\n")
  455. if postconfig:
  456. stream.write(postconfig + "\n")
  457. @classmethod
  458. def _remove_from_gitolite_cache(cls, cache_file, project):
  459. """Removes project from gitolite cache file (gl-conf.cache)
  460. Gitolite has no notion of "deleting" a project and it can only
  461. add values to gl-conf.cache. Therefore we must manually wipe all
  462. entries related to a project when deleting it.
  463. If this method is not executed and if someone creates a project
  464. with the same fullname again then its `gl-conf` file won't get
  465. created (see link to commit below) and any subsequent invocation of
  466. `gitolite trigger POST_COMPILE` will fail, thus preventing creation
  467. of new repos/forks at the whole pagure instance.
  468. See https://github.com/sitaramc/gitolite/commit/41b7885b77c
  469. (later reverted upstream, but still used in most Pagure deployments)
  470. :arg cls: the current class
  471. :type: Gitolite2Auth
  472. :arg cache_file: path to the cache file
  473. :type cache_file: str
  474. :arg project: the project to remove from gitolite cache file
  475. :type project: pagure.lib.model.Project
  476. """
  477. _log.info("Remove project from the gitolite cache file")
  478. cf = None
  479. try:
  480. # unfortunately dbm_gnu.open isn't a context manager in Python 2 :(
  481. cf = dbm_gnu.open(cache_file, "ws")
  482. for repo in ["", "docs/", "tickets/", "requests/"]:
  483. to_remove = repo + project.fullname
  484. if to_remove.encode("ascii") in cf:
  485. del cf[to_remove]
  486. except dbm_gnu.error as e:
  487. msg = "Failed to remove project from gitolite cache: {msg}".format(
  488. msg=e[1]
  489. )
  490. raise pagure.exceptions.PagureException(msg)
  491. finally:
  492. if cf:
  493. cf.close()
  494. @classmethod
  495. def remove_acls(cls, session, project):
  496. """Remove a project from the configuration file for gitolite.
  497. :arg cls: the current class
  498. :type: Gitolite2Auth
  499. :arg session: the session with which to connect to the database
  500. :arg project: the project to remove from the gitolite configuration
  501. file.
  502. :type project: pagure.lib.model.Project
  503. """
  504. _log.info("Remove project from the gitolite configuration file")
  505. if not project:
  506. raise RuntimeError("Project undefined")
  507. configfile = pagure_config["GITOLITE_CONFIG"]
  508. preconf = pagure_config.get("GITOLITE_PRE_CONFIG") or None
  509. postconf = pagure_config.get("GITOLITE_POST_CONFIG") or None
  510. if not os.path.exists(configfile):
  511. _log.info(
  512. "Not configuration file found at: %s... bailing" % configfile
  513. )
  514. return
  515. preconfig = None
  516. if preconf:
  517. _log.info(
  518. "Loading the file to include at the top of the generated one"
  519. )
  520. preconfig = _read_file(preconf)
  521. postconfig = None
  522. if postconf:
  523. _log.info(
  524. "Loading the file to include at the end of the generated one"
  525. )
  526. postconfig = _read_file(postconf)
  527. config = []
  528. groups = cls._generate_groups_config(session)
  529. _log.info("Removing the project from the configuration")
  530. current_config = cls._get_current_config(
  531. configfile, preconfig, postconfig
  532. )
  533. current_config = cls._clean_current_config(current_config, project)
  534. config = current_config + config
  535. if config:
  536. _log.info("Cleaning the groups from the loaded config")
  537. config = cls._clean_groups(config)
  538. else:
  539. current_config = cls._get_current_config(
  540. configfile, preconfig, postconfig
  541. )
  542. _log.info("Cleaning the groups from the config on disk")
  543. config = cls._clean_groups(config)
  544. if not config:
  545. return
  546. _log.info("Writing the configuration to: %s", configfile)
  547. with open(configfile, "w", encoding="utf-8") as stream:
  548. if preconfig:
  549. stream.write(preconfig + "\n")
  550. stream.write("# end of header\n")
  551. if groups:
  552. for key in sorted(groups):
  553. stream.write("@%s = %s\n" % (key, " ".join(groups[key])))
  554. stream.write("# end of groups\n\n")
  555. prev = None
  556. for row in config:
  557. if prev is None:
  558. prev = row
  559. if prev == row == "":
  560. continue
  561. stream.write(row + "\n")
  562. prev = row
  563. stream.write("# end of body\n")
  564. if postconfig:
  565. stream.write(postconfig + "\n")
  566. gl_cache_path = os.path.join(
  567. os.path.dirname(configfile), "..", "gl-conf.cache"
  568. )
  569. if os.path.exists(gl_cache_path):
  570. cls._remove_from_gitolite_cache(gl_cache_path, project)
  571. @staticmethod
  572. def _get_gitolite_command():
  573. """Return the gitolite command to run based on the info in the
  574. configuration file.
  575. """
  576. _log.info("Compiling the gitolite configuration")
  577. gitolite_folder = pagure_config.get("GITOLITE_HOME", None)
  578. if gitolite_folder:
  579. cmd = "GL_RC=%s GL_BINDIR=%s gl-compile-conf" % (
  580. pagure_config.get("GL_RC"),
  581. pagure_config.get("GL_BINDIR"),
  582. )
  583. _log.debug("Command: %s", cmd)
  584. return cmd
  585. @classmethod
  586. def _repos_from_lines(cls, lines):
  587. """Return list of strings representing complete repo entries from list
  588. of lines as returned by _process_project.
  589. """
  590. repos = []
  591. for line in lines:
  592. if line.startswith("repo "):
  593. repos.append([line])
  594. else:
  595. repos[-1].append(line)
  596. for i, repo_lines in enumerate(repos):
  597. repos[i] = "\n".join(repo_lines)
  598. return repos
  599. @classmethod
  600. def _run_gitolite_cmd(cls, cmd):
  601. """Run gitolite command as subprocess, raise PagureException
  602. if it fails.
  603. """
  604. if cmd:
  605. proc = subprocess.Popen(
  606. cmd,
  607. shell=True,
  608. stdout=subprocess.PIPE,
  609. stderr=subprocess.PIPE,
  610. cwd=pagure_config["GITOLITE_HOME"],
  611. )
  612. stdout, stderr = proc.communicate()
  613. if proc.returncode != 0:
  614. error_msg = (
  615. 'The command "{0}" failed with'
  616. '\n\n out: "{1}\n\n err:"{2}"'.format(
  617. cmd, stdout, stderr
  618. )
  619. )
  620. raise pagure.exceptions.PagureException(error_msg)
  621. @classmethod
  622. def generate_acls(cls, project, group=None):
  623. """Generate the gitolite configuration file for all repos
  624. :arg project: the project to update in the gitolite configuration
  625. file. It can be of three types/values.
  626. If it is ``-1`` or if the file does not exist on disk, the
  627. entire gitolite configuration will be re-generated.
  628. If it is ``None``, the gitolite configuration will not be
  629. changed but will be re-compiled.
  630. If it is a ``pagure.lib.model.Project``, the gitolite
  631. configuration will be updated for just this project.
  632. :type project: None, int or pagure.lib.model.Project
  633. :kwarg group: the group to refresh the members of
  634. :type group: None or pagure.lib.model.PagureGroup
  635. """
  636. _log.info("Refresh gitolite configuration")
  637. if project is not None or group is not None:
  638. session = pagure.lib.model_base.create_session(
  639. pagure_config["DB_URL"]
  640. )
  641. cls.write_gitolite_acls(
  642. session,
  643. project=project,
  644. configfile=pagure_config["GITOLITE_CONFIG"],
  645. preconf=pagure_config.get("GITOLITE_PRE_CONFIG") or None,
  646. postconf=pagure_config.get("GITOLITE_POST_CONFIG") or None,
  647. group=group,
  648. )
  649. session.remove()
  650. if (
  651. not group
  652. and project not in [None, -1]
  653. and hasattr(cls, "_individual_repos_command")
  654. and pagure_config.get("GITOLITE_HAS_COMPILE_1", False)
  655. ):
  656. # optimization for adding single repo - we don't want to recompile
  657. # whole gitolite.conf
  658. repos_config = []
  659. cls._process_project(
  660. project, repos_config, pagure_config.get("PR_ONLY", False)
  661. )
  662. # repos_config will contain lines for repo itself as well as
  663. # docs, requests, tickets; compile-1 only accepts one repo,
  664. # so we have to run it separately for all of them
  665. for repo in cls._repos_from_lines(repos_config):
  666. repopath = repo.splitlines()[0][len("repo ") :].strip()
  667. repotype = repopath.split("/")[0]
  668. if (
  669. repotype == "docs" and not pagure_config.get("ENABLE_DOCS")
  670. ) or (
  671. repotype == "tickets"
  672. and not pagure_config.get("ENABLE_TICKETS")
  673. ):
  674. continue
  675. with tempfile.NamedTemporaryFile() as f:
  676. f.write(repo)
  677. f.flush()
  678. cmd = cls._individual_repos_command(f.name)
  679. cls._run_gitolite_cmd(cmd)
  680. else:
  681. cmd = cls._get_gitolite_command()
  682. cls._run_gitolite_cmd(cmd)
  683. class Gitolite3Auth(Gitolite2Auth):
  684. """A gitolite 3 authentication module."""
  685. @staticmethod
  686. def _individual_repos_command(config_file):
  687. _log.info(
  688. "Compiling gitolite configuration %s for single repository",
  689. config_file,
  690. )
  691. gitolite_folder = pagure_config.get("GITOLITE_HOME", None)
  692. if gitolite_folder:
  693. cmd = "HOME=%s gitolite compile-1 %s" % (
  694. gitolite_folder,
  695. config_file,
  696. )
  697. _log.debug("Command: %s", cmd)
  698. return cmd
  699. @staticmethod
  700. def _get_gitolite_command():
  701. """Return the gitolite command to run based on the info in the
  702. configuration file.
  703. """
  704. _log.info("Compiling the gitolite configuration")
  705. gitolite_folder = pagure_config.get("GITOLITE_HOME", None)
  706. if gitolite_folder:
  707. cmd = (
  708. "HOME=%s gitolite compile && HOME=%s gitolite trigger "
  709. "POST_COMPILE" % (gitolite_folder, gitolite_folder)
  710. )
  711. _log.debug("Command: %s", cmd)
  712. return cmd
  713. @classmethod
  714. def post_compile_only(cls):
  715. """This method runs `gitolite trigger POST_COMPILE` without touching
  716. any other gitolite configuration. Most importantly, this will process
  717. SSH keys used by gitolite.
  718. """
  719. _log.info("Triggering gitolite POST_COMPILE")
  720. gitolite_folder = pagure_config.get("GITOLITE_HOME", None)
  721. if gitolite_folder:
  722. cmd = "HOME=%s gitolite trigger POST_COMPILE" % gitolite_folder
  723. _log.debug("Command: %s", cmd)
  724. cls._run_gitolite_cmd(cmd)
  725. class PagureGitAuth(GitAuthHelper):
  726. """Standard Pagure git auth implementation."""
  727. is_dynamic = True
  728. @classmethod
  729. def generate_acls(self, project, group=None):
  730. """This function is required but not used."""
  731. pass
  732. @classmethod
  733. def remove_acls(self, session, project):
  734. """This function is required but not used."""
  735. pass
  736. def info(self, msg):
  737. """Function that prints info about decisions to clients.
  738. This is a function to make it possible to override for test suite."""
  739. print(msg)
  740. def check_acl(
  741. self,
  742. session,
  743. project,
  744. username,
  745. refname,
  746. pull_request,
  747. repotype,
  748. is_internal,
  749. **info
  750. ):
  751. if is_internal:
  752. self.info("Internal push allowed")
  753. return True
  754. # Check whether a PR is required for this repo or in general
  755. global_pr_only = pagure_config.get("PR_ONLY", False)
  756. pr_only = project.settings.get("pull_request_access_only", False)
  757. if repotype == "main":
  758. if (
  759. pr_only or (global_pr_only and not project.is_fork)
  760. ) and not pull_request:
  761. self.info("Pull request required")
  762. return False
  763. if username is None:
  764. return False
  765. # Determine whether the current user is allowed to push
  766. is_committer = is_repo_collaborator(
  767. project, refname, username, session
  768. )
  769. deploykey = lookup_deploykey(project, username)
  770. if deploykey is not None:
  771. self.info("Deploykey used. Push access: %s" % deploykey.pushaccess)
  772. is_committer = deploykey.pushaccess
  773. self.info("Has commit access: %s" % is_committer)
  774. return is_committer
  775. class GitAuthTestHelper(GitAuthHelper):
  776. """Simple test auth module to check the auth customization system."""
  777. is_dynamic = True
  778. @classmethod
  779. def generate_acls(cls, project, group=None):
  780. """Print a statement when called, useful for debugging, only.
  781. :arg project: this variable is just printed out but not used
  782. in any real place.
  783. :type project: None, int or spagure.lib.model.Project
  784. :kwarg group: the group to refresh the members of
  785. :type group: None or pagure.lib.model.PagureGroup
  786. """
  787. out = (
  788. "Called GitAuthTestHelper.generate_acls() "
  789. "with args: project=%s, group=%s" % (project, group)
  790. )
  791. print(out)
  792. return out
  793. @classmethod
  794. def remove_acls(cls, session, project):
  795. """Print a statement about which a project would be removed from
  796. the configuration file for gitolite.
  797. :arg cls: the current class
  798. :type: GitAuthHelper
  799. :arg session: the session with which to connect to the database
  800. :arg project: the project to remove from the gitolite configuration
  801. file.
  802. :type project: pagure.lib.model.Project
  803. """
  804. out = (
  805. "Called GitAuthTestHelper.remove_acls() "
  806. "with args: project=%s" % (project.fullname)
  807. )
  808. print(out)
  809. return out
  810. @classmethod
  811. def check_acl(
  812. cls, session, project, username, refname, pull_request, **info
  813. ):
  814. testfile = pagure_config.get("TEST_AUTH_STATUS", None)
  815. if not testfile or not os.path.exists(testfile):
  816. # If we are not configured, we will assume allowed
  817. return True
  818. with open(testfile, "r") as statusfile:
  819. status = json.loads(statusfile.read())
  820. if status is True or status is False:
  821. return status
  822. # Other option would be a dict with ref->allow
  823. # (with allow True, pronly), missing means False)
  824. if refname not in status:
  825. print("ref '%s' not in status" % refname)
  826. return False
  827. elif status[refname] is True:
  828. return True
  829. elif status[refname] == "pronly":
  830. return pull_request is not None