From 2135942e49989c10c73464b1bdfdf490e8edd24a Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 4 Mar 2017 16:13:24 +0200 Subject: Initial commit --- 000.yarn | 286 ++++++++++++++++++++++++++++++++++++++++++++++++++++ check | 17 ++++ lib.py | 7 ++ without-tests | 1 + yarnhelper.py | 140 +++++++++++++++++++++++++ yarnhelper_tests.py | 132 ++++++++++++++++++++++++ 6 files changed, 583 insertions(+) create mode 100644 000.yarn create mode 100755 check create mode 100644 lib.py create mode 100644 without-tests create mode 100644 yarnhelper.py create mode 100644 yarnhelper_tests.py 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 + + + + +# 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 . +# +# =*= 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 . +# +# =*= 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') -- cgit v1.2.1