summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2017-03-04 16:13:24 +0200
committerLars Wirzenius <liw@liw.fi>2017-03-04 16:13:24 +0200
commit2135942e49989c10c73464b1bdfdf490e8edd24a (patch)
tree42fe0816451ab273ef68764ef3aaf87d87c6d046
downloadgit.liw.fi-ruleset-tests-2135942e49989c10c73464b1bdfdf490e8edd24a.tar.gz
Initial commit
-rw-r--r--000.yarn286
-rwxr-xr-xcheck17
-rw-r--r--lib.py7
-rw-r--r--without-tests1
-rw-r--r--yarnhelper.py140
-rw-r--r--yarnhelper_tests.py132
6 files changed, 583 insertions, 0 deletions
diff --git a/000.yarn b/000.yarn
new file mode 100644
index 0000000..7f72fc9
--- /dev/null
+++ b/000.yarn
@@ -0,0 +1,286 @@
+---
+title: "A Gitano ruleset: tests"
+author: Lars Wirzenius (liw@liw.fi)
+date: 2017-02-04
+...
+
+[Gitano][] is the server software I use for my personal git server,
+and also for work. One of Gitano's primary features is a strong
+language for defining access control, called [Lace][]. However, with
+great power comes long shoelaces to tie together. This document
+explains what my rules set does, and why. It is also a test suite for
+the ruleset, to make sure it does what it's supposed to, but avoids
+some of the more obvious pitfalls.
+
+[Gitano]: https://www.gitano.org.uk/
+[Lace]: https://www.gitano.org.uk/lace/
+
+
+Personas for use cases
+-----------------------------------------------------------------------------
+
+* **Ian Inhouse Developer** is a staff member and works on a free software
+ project with the random silly name Qvarn.
+
+* **Olive Opshead** is a sysadmin and needs to maintain ops related
+ repositories, some of which are sensitive.
+
+* **Steven Sect** is the company secretary and needs to access some
+ private respositories with internal company data.
+
+* **Gabriella Guest** is an outside developer, collaborating on Qvarn
+ with Ian. Gabriella has been granted restricted commit access to the
+ Qvarn repository.
+
+* **Tina Thirdparty** is an outside developer working on Qvarn as a
+ full member, and needs full write access to the repository.
+
+* **J. R. Hacker** is an outsider who contributes to the projects, but
+ has no need to commit to any the repositories. Instead, they
+ maintain their own repository elsewhere, and Ian or Tina access that.
+
+* **CI** is an automated system that monitors some repositories to
+ build, test, and deliver software.
+
+
+Use cases
+-----------------------------------------------------------------------------
+
+* Ian fixes a bug in Qvarn. (Or adds a feature: same process.)
+
+ * Ian clones Qvarn to his laptop, or updates his existing clone.
+ * Ian creates a branch in the local clone and makes changes.
+ * Ian pushes the branch to the git server.
+ * After others have reviewed his changes, Ian merges them to
+ master, and pushes master to the git server.
+ * CI notices the change, runs tests, and builds and publishes a CI
+ build of Qvarn.
+
+* Ian makes a release.
+
+ * Ian clones Qvarn.
+ * Ian tags the right commit with a release tag.
+ * Ian pushes the tag to the git server.
+ * CI updates its own git clone; new release tag triggers a release
+ build.
+
+* Olive provisions a new Qvarn instance.
+
+ * Olive clones a repository with Ansible playbooks.
+ * Olive clones a repository with encrypted passowords.
+ * Olive creates a local branch in the Ansible playbooks.
+ * Olive makes and commits any necessary changes to the playbooks.
+ * Olive creates a new VM and runs Ansible.
+ * Olive pushes her new local branch to the server for review.
+ * After revies feedback, Olive merges her changes to master.
+ * Olive pushes master changes to server.
+
+* Steven updates internal wiki.
+
+ * Steven clones intrawiki.git.
+ * Steven makes changes.
+ * Steven commits changes to local master branch.
+ * Steven pushes master changes to git server.
+ * CI notices changes and updated intrawiki website.
+
+* Gabriella makes a bug fix.
+
+ * Gabriella clones qvarn.git.
+ * Gabriella creates a new branch.
+ * Gabriella changes new branch to fix bug.
+ * Gabriella pushes new branch to git server.
+ * Gabriella notifies others of new branch.
+ * Ian reviews branch and finds it good.
+ * Ian merges branch to master.
+ * CI notices change in master and runs a CI pipeline.
+
+* JR makes a bug fix.
+
+ * JR clones qvarn.git.
+ * JR creates a new local branch.
+ * JR fixes bug in local branch.
+ * JR pushes local branch to his own git server.
+ * JR notifies others of changes on his git server.
+ * Ian reviews changes from JR'r server, merges them to master from
+ git.qvarnlabs.net, and pushes new master to git.q.n.
+ * CI notices changes to master and runs CI pipeline.
+
+* Anyone but Olive tries to clone the secrets repository
+
+ * Gabriella attempts to clone the secrets repository.
+ * The attempt fails.
+
+* Tina adds a feature to another project, matching unreleased changes
+ to Qvarn.
+
+ * Tina clones qvarn.git.
+ * Tina clones other-project.git.
+ * Tina makes a change in other-project.git.
+ * Tina pushes change in master to tina-project.git.
+
+Discussion
+-----------------------------------------------------------------------------
+
+* There's going to be repositories that are public to the world (in a
+ read-only manner) and those that are meant to be private at some
+ level. Private may mean private to all staff or private to only
+ specific people (e.g., ops secrets).
+
+* Some people will have write access to some repositories, whereas
+ everyone else (even with Gitano accounts) have only read access to
+ those respositories. Example, Steven doesn't need write access to
+ the Qvarn repository, but there's no point in restricting read
+ access, since it's a public repository.
+
+* When outsiders have write access, it may need to be restricted, such
+ that they can do things, but can't change master or other branches
+ themselves, or create release tags. This prevents Gary from making
+ releases, or changing master without anyone else knowing about it.
+
+Some principles/suggestions
+-----------------------------------------------------------------------------
+
+* There's several groups of people. Each group needs different access.
+
+ - implicitly trusted staff members (those who can do ops)
+ - normal staff members (Steven)
+ - outsider guest users (Gabriella)
+ - automated systems (CI)
+
+* Three types of repositories.
+
+ - completely public (qvarn.git)
+ - private, but public to staff (intrawiki.git)
+ - accessible to just part of staff (ops secrets)
+
+* Guests should be able to update public repos in restricted ways.
+
+ - they may only create/modify branches that are not used by
+ others, e.g., their names are prefixed by their Gitano username
+ - they may only create tags not used by others (again, prefixed),
+ so that they may not create release tags
+
+* Staff is either developers or otherwise trusted (i.e., can modify
+ any branches, can make any tags), or treated the same as guests.
+
+* Even staff may only have full access to some repos (qvarn.git), but
+ not all (ops ones). For the latter, treat them similar to guests.
+
+* CI and other automated systems only have read-only access. If an
+ automated system needs write access later, consider the implications
+ at that time.
+
+Rough outline for ruleset
+-----------------------------------------------------------------------------
+
+* All repositories should have three configuration variables,
+ `readers`, `writers`, and `guests`. The value of each varibable is
+ the name of a Gitano group. Access is granted to users in the named
+ group.
+
+* Public repositories are marked by setting the configuration variable
+ `public` to `yes`. Anyone can clone public repositories, pull from
+ them, and cgit shows them to anyone.
+
+
+Scenarios
+-----------------------------------------------------------------------------
+
+These [yarn][] scenarios need to be run as a Gitano user who is in the
+gitano-admins group so that they can create users, and do other
+priviledged operations. Further, this should be done against a fresh
+Gitano, without the test users etc.
+
+[yarn]: http://liw.fi/cmdtest/
+
+This is going to be a long scenario, but that's just so that we don't
+need to re-do the setup. The setup consists of creating test users,
+groups, and respositories.
+
+ SCENARIO create users, groups, repositories
+ WHEN we run gitano whoami
+ THEN we are in group gitano-admin
+
+<!--
+
+ WHEN we create user ian
+ AND we create user olive
+ AND we create user steven
+ AND we create user gabriella
+ AND we create user tina
+ AND we create user ci
+
+ WHEN we create group qvarndevs
+ AND we create group qvarnguests
+ AND we create group ops
+ AND we create group otherdevs
+ AND we create group staff
+
+ WHEN we add ian to qvarndevs
+ AND we add tina to qvarndevs
+ AND we add gabriells to qvarnguests
+ AND we add olive to ops
+ AND we add tina to otherdevs
+ AND we add steven to staff
+
+ WHEN we create repository qvarn
+ AND we create repository ops/secrets
+ AND we create repository ops/ansible
+ AND we create repository intrawiki
+ AND we create repository otherproject
+
+ WHEN we set qvarn config writers to qvarndevs
+ AND we set qvarn config guests to qvarnguests
+ AND we set qvarn config public to yes
+ AND we set ops/secrets config writers to ops
+ AND we set ops/ansible config writers to ops
+ AND we set intrawiki config writers to staff
+
+Now we can start defining use cases.
+
+Use case: Ian makes a bugfix to Qvarn.
+
+ WHEN ci clonse qvarn
+ AND ian clones qvarn
+ AND ian creates local qvarn branch ian/bugfix
+ AND ian pushes qvarn
+ AND ian merges ian/bugfix into master
+ AND ian pushes qvarn
+ THEN ci sees new commit in qvarn
+
+Use case: Ian makes a release.
+
+ WHEN ian creates tag qvarn-1.0 in qvarn master
+ AND ian pushes qvarn
+ THEN ci sees tag qvarn-1.0 in qvarn
+
+Use case: Olive provisions a new Qvarn instance.
+
+ WHEN olive clones ops/ansible
+ AND olive clones ops/secrets
+ AND olive creates local ops/ansible branch newthing
+ AND olive pushes ops/ansible
+ AND merges ops/ansible branch newthing to master
+ AND olive pushes ops/ansible
+
+Use case: Steven updates internal wiki.
+
+ WHEN ci clones intrawiki
+ AND steven clones intrawiki
+ AND steven makes a change in intrawiki master
+ AND steven pushes intrawiki
+ THEN ci sees a new commit in intrawiki
+
+-->
+
+
+# Scenario step implementations
+
+ IMPLEMENTS WHEN we run gitano (.+)
+ args = helper.get_next_match()
+ whoami_output = helper.gitano(args)
+ helper.set_variable('admin_whoami', whoami_output)
+
+ IMPLEMENTS THEN we are in group gitano-admin
+ whoami = helper.get_variable('admin_whoami')
+ helper.assertIn('gitano-admin', whoami)
diff --git a/check b/check
new file mode 100755
index 0000000..ae85315
--- /dev/null
+++ b/check
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+set -eu
+
+server="$1"
+shift 1
+
+python -m CoverageTestRunner --ignore-missing-from=without-tests .
+yarn \
+ --env "GITANO_SERVER=$server" \
+ --shell=python2 \
+ --shell-arg '' \
+ --shell-library lib.py \
+ --env "PYTHONPATH=$(pwd)" \
+ --env "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" \
+ --cd-datadir \
+ *.yarn "$@"
diff --git a/lib.py b/lib.py
new file mode 100644
index 0000000..fe39735
--- /dev/null
+++ b/lib.py
@@ -0,0 +1,7 @@
+import os
+
+import cliapp
+
+import yarnhelper
+
+helper = yarnhelper.YarnHelper()
diff --git a/without-tests b/without-tests
new file mode 100644
index 0000000..f95a6db
--- /dev/null
+++ b/without-tests
@@ -0,0 +1 @@
+lib.py \ No newline at end of file
diff --git a/yarnhelper.py b/yarnhelper.py
new file mode 100644
index 0000000..5b087be
--- /dev/null
+++ b/yarnhelper.py
@@ -0,0 +1,140 @@
+# Copyright 2016 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import email
+import imaplib
+import os
+import subprocess
+import urlparse
+
+import cliapp
+import requests
+import yaml
+
+
+variables_filename = os.environ.get('VARIABLES', 'vars.yaml')
+
+
+class YarnHelper(object):
+
+ def __init__(self):
+ self._env = dict(os.environ)
+ self._next_match = 1
+ self._variables = None # None means not loaded, otherwise dict
+
+ def set_environment(self, env):
+ self._env = dict(env)
+
+ def get_next_match(self):
+ name = 'MATCH_{}'.format(self._next_match)
+ if name not in self._env:
+ raise Error('no next match')
+ self._next_match += 1
+ return self._env[name]
+
+ def get_variable(self, name):
+ if self._variables is None:
+ self._variables = self._load_variables()
+ if name not in self._variables:
+ raise Error('no variable {}'.format(name))
+ return self._variables[name]
+
+ def _load_variables(self):
+ if os.path.exists(variables_filename):
+ with open(variables_filename, 'r') as f:
+ return yaml.safe_load(f)
+ return {}
+
+ def set_variable(self, name, value):
+ if self._variables is None:
+ self._variables = {}
+ self._variables[name] = value
+ self._save_variables(self._variables)
+
+ def _save_variables(self, variables):
+ with open(variables_filename, 'w') as f:
+ yaml.safe_dump(variables, f)
+
+ def construct_aliased_http_request(
+ self, address, method, url, data=None, headers=None):
+
+ if headers is None:
+ headers = {}
+
+ parts = list(urlparse.urlparse(url))
+ headers['Host'] = parts[1]
+ parts[1] = address
+ aliased_url = urlparse.urlunparse(parts)
+
+ r = requests.Request(method, aliased_url, data=data, headers=headers)
+ return r.prepare()
+
+ def http_get(self, address, url): # pragma: no cover
+ r = self.construct_aliased_http_request(address, 'GET', url)
+ s = requests.Session()
+ resp = s.send(r)
+ return resp.status_code, resp.content
+
+ def assertEqual(self, a, b):
+ if a != b:
+ raise Error('assertion {!r} == {!r} failed'.format(a, b))
+
+ def assertNotEqual(self, a, b):
+ if a == b:
+ raise Error('assertion {!r} != {!r} failed'.format(a, b))
+
+ def assertGreaterThan(self, a, b):
+ if a <= b:
+ raise Error('assertion {!r} > {!r} failed'.format(a, b))
+
+ def assertIn(self, a, b):
+ if a not in b:
+ raise Error('assertion {!r} in {!r} failed'.format(a, b))
+
+ def get_password_with_pass(self, pass_home, pass_name): # pragma: no cover
+ p = subprocess.Popen(
+ ['env', 'HOME={}'.format(pass_home), 'pass', 'show', pass_name],
+ stdout=subprocess.PIPE)
+ stdout, stderr = p.communicate()
+ password = stdout.rstrip()
+ return password
+
+ def iterate_mails_in_imap_mailbox(
+ self, address, user, password, callback, exp): # pragma: no cover
+ m = imaplib.IMAP4_SSL(address)
+ m.login(user, password)
+ m.select('INBOX', False)
+ typ, data = m.search(None, 'ALL')
+ for num in data[0].split():
+ typ, data = m.fetch(num, '(RFC822)')
+ typ, text = data[0]
+ msg = email.message_from_string(text)
+ callback(m, num, msg)
+ if exp:
+ m.expunge()
+ m.close()
+ m.logout()
+
+ def gitano(self, args): # pragma: no cover
+ server = os.environ['GITANO_SERVER']
+ return cliapp.ssh_runcmd('git@{}'.format(server), [args])
+
+
+class Error(Exception):
+
+ pass
diff --git a/yarnhelper_tests.py b/yarnhelper_tests.py
new file mode 100644
index 0000000..e5cab3f
--- /dev/null
+++ b/yarnhelper_tests.py
@@ -0,0 +1,132 @@
+# Copyright 2016 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import os
+import unittest
+
+import yarnhelper
+
+
+class GetNextMatchTests(unittest.TestCase):
+
+ def test_raises_error_if_no_next_match(self):
+ h = yarnhelper.YarnHelper()
+ h.set_environment({})
+ with self.assertRaises(yarnhelper.Error):
+ h.get_next_match()
+
+ def test_returns_first_match_if_there(self):
+ h = yarnhelper.YarnHelper()
+ h.set_environment({
+ 'MATCH_1': 'first',
+ })
+ self.assertEqual(h.get_next_match(), 'first')
+
+ def test_returns_second_match_if_there(self):
+ h = yarnhelper.YarnHelper()
+ h.set_environment({
+ 'MATCH_1': 'first',
+ 'MATCH_2': 'second',
+ })
+ self.assertEqual(h.get_next_match(), 'first')
+ self.assertEqual(h.get_next_match(), 'second')
+
+ def test_raises_error_if_no_more_matches(self):
+ h = yarnhelper.YarnHelper()
+ h.set_environment({
+ 'MATCH_1': 'first',
+ })
+ self.assertEqual(h.get_next_match(), 'first')
+ with self.assertRaises(yarnhelper.Error):
+ h.get_next_match()
+
+
+class PersistentVariableTests(unittest.TestCase):
+
+ def setUp(self):
+ # We need this so that tearDown works
+ pass
+
+ def tearDown(self):
+ if os.path.exists(yarnhelper.variables_filename):
+ os.remove(yarnhelper.variables_filename)
+
+ def test_raises_error_if_no_such_variable(self):
+ h = yarnhelper.YarnHelper()
+ with self.assertRaises(yarnhelper.Error):
+ h.get_variable('FOO')
+ print
+ print 'variables:', h._variables
+
+ def test_sets_variable_persistently(self):
+ h = yarnhelper.YarnHelper()
+ h.set_variable('FOO', 'bar')
+
+ h2 = yarnhelper.YarnHelper()
+ self.assertEqual(h2.get_variable('FOO'), 'bar')
+
+
+class HttpTests(unittest.TestCase):
+
+ def test_constructs_aliased_request(self):
+ h = yarnhelper.YarnHelper()
+ server = 'new.example.com'
+ url = 'http://www.example.com/path'
+ r = h.construct_aliased_http_request(server, 'GET', url)
+ self.assertEqual(r.url, 'http://new.example.com/path')
+ self.assertEqual(r.headers['Host'], 'www.example.com')
+
+
+class AssertionTests(unittest.TestCase):
+
+ def test_assertEqual_asserts_equals_correctly(self):
+ h = yarnhelper.YarnHelper()
+ self.assertEqual(h.assertEqual(0, 0), None)
+
+ def test_assertEqual_raises_error_for_nonequal_values(self):
+ h = yarnhelper.YarnHelper()
+ with self.assertRaises(yarnhelper.Error):
+ h.assertEqual(0, 1)
+
+ def test_assertNotEqual_asserts_nonequal_correct(self):
+ h = yarnhelper.YarnHelper()
+ self.assertEqual(h.assertNotEqual(0, 1), None)
+
+ def test_assertNotEqual_raises_error_for_equal_values(self):
+ h = yarnhelper.YarnHelper()
+ with self.assertRaises(yarnhelper.Error):
+ h.assertNotEqual(0, 0)
+
+ def test_assertGreaterThan_raises_error_for_equal_values(self):
+ h = yarnhelper.YarnHelper()
+ with self.assertRaises(yarnhelper.Error):
+ h.assertGreaterThan(0, 0)
+
+ def test_assertGreaterThan_raises_error_for_unordered_values(self):
+ h = yarnhelper.YarnHelper()
+ with self.assertRaises(yarnhelper.Error):
+ h.assertGreaterThan(0, 1)
+
+ def test_assertIn_asserts_correctly(self):
+ h = yarnhelper.YarnHelper()
+ self.assertEqual(h.assertIn('ana', 'banana'), None)
+
+ def test_assertIn_raises_error_for_false(self):
+ h = yarnhelper.YarnHelper()
+ with self.assertRaises(yarnhelper.Error):
+ h.assertIn('nope', 'banana')