Skip to content

Commit

Permalink
bug 1310209: Implement SignoffsTable and web apis for it. (#181). r=n…
Browse files Browse the repository at this point in the history
…thomas,jlorenzo
  • Loading branch information
bhearsum committed Dec 2, 2016
1 parent a72bbc2 commit e9a7d5f
Show file tree
Hide file tree
Showing 14 changed files with 489 additions and 27 deletions.
6 changes: 4 additions & 2 deletions auslib/admin/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
from auslib.admin.views.rules import RulesAPIView, \
SingleRuleView, RuleHistoryAPIView, SingleRuleColumnView, \
RuleScheduledChangesView, RuleScheduledChangeView, \
EnactRuleScheduledChangeView, RuleScheduledChangeHistoryView
EnactRuleScheduledChangeView, RuleScheduledChangeHistoryView, \
RuleScheduledChangeSignoffsView
from auslib.admin.views.history import DiffView, FieldView
from auslib.dockerflow import create_dockerflow_endpoints

Expand Down Expand Up @@ -88,5 +89,6 @@ def add_security_headers(response):
app.add_url_rule("/history/view/<type_>/<change_id>/<field>", view_func=FieldView.as_view("field"))
app.add_url_rule("/scheduled_changes/rules", view_func=RuleScheduledChangesView.as_view("scheduled_changes_rules"))
app.add_url_rule("/scheduled_changes/rules/<int:sc_id>", view_func=RuleScheduledChangeView.as_view("scheduled_change_rules"))
app.add_url_rule("/scheduled_changes/rules/<int:sc_id>/enact", view_func=EnactRuleScheduledChangeView.as_view("ench_scheduled_change_rules"))
app.add_url_rule("/scheduled_changes/rules/<int:sc_id>/enact", view_func=EnactRuleScheduledChangeView.as_view("enact_scheduled_change_rules"))
app.add_url_rule("/scheduled_changes/rules/<int:sc_id>/signoffs", view_func=RuleScheduledChangeSignoffsView.as_view("scheduled_change_rules_signoffs"))
app.add_url_rule("/scheduled_changes/rules/<int:sc_id>/revisions", view_func=RuleScheduledChangeHistoryView.as_view("scheduled_change_rules_history"))
3 changes: 1 addition & 2 deletions auslib/admin/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ def decorated(*args, **kwargs):
logging.warning(e)
return Response(status=400, response=json.dumps({"exception": msg}), mimetype="application/json")
except PermissionDeniedError as e:
msg = "Permission denied to perform the request"
msg = "Permission denied to perform the request. {}".format(e.message)
logging.warning(msg)
logging.warning(e)
return Response(status=403, response=json.dumps({"exception": msg}), mimetype="application/json")
return decorated
return wrap
Expand Down
4 changes: 4 additions & 0 deletions auslib/admin/views/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ class EditScheduledChangeExistingRuleForm(ScheduledChangeForm, EditRuleForm):
sc_data_version = IntegerField('sc_data_version', validators=[Required()], widget=HiddenInput())


class SignoffForm(Form):
role = StringField('Role', validators=[Required()])


class CompleteReleaseForm(Form):
name = StringField('Name', validators=[Required()])
product = StringField('Product', validators=[Required()])
Expand Down
16 changes: 15 additions & 1 deletion auslib/admin/views/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
ScheduledChangeNewRuleForm, ScheduledChangeExistingRuleForm, \
EditScheduledChangeNewRuleForm, EditScheduledChangeExistingRuleForm
from auslib.admin.views.scheduled_changes import ScheduledChangesView, \
ScheduledChangeView, EnactScheduledChangeView, ScheduledChangeHistoryView
ScheduledChangeView, EnactScheduledChangeView, ScheduledChangeHistoryView, \
SignoffsView


class RulesAPIView(AdminView):
Expand Down Expand Up @@ -341,6 +342,19 @@ def _post(self, sc_id, transaction, changed_by):
return super(EnactRuleScheduledChangeView, self)._post(sc_id, transaction, changed_by)


class RuleScheduledChangeSignoffsView(SignoffsView):
def __init__(self):
super(RuleScheduledChangeSignoffsView, self).__init__("rules", dbo.rules)

@requirelogin
def _post(self, sc_id, transaction, changed_by):
return super(RuleScheduledChangeSignoffsView, self)._post(sc_id, transaction, changed_by)

@requirelogin
def _delete(self, sc_id, transaction, changed_by):
return super(RuleScheduledChangeSignoffsView, self)._delete(sc_id, transaction, changed_by)


class RuleScheduledChangeHistoryView(ScheduledChangeHistoryView):
def __init__(self):
super(RuleScheduledChangeHistoryView, self).__init__("rules", dbo.rules)
Expand Down
49 changes: 47 additions & 2 deletions auslib/admin/views/scheduled_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from sqlalchemy.sql.expression import null

from flask import jsonify, request, Response
from flask_wtf import Form

from auslib.admin.views.base import AdminView, HistoryAdminView
from auslib.admin.views.forms import DbEditableForm
from auslib.admin.views.forms import DbEditableForm, SignoffForm


class ScheduledChangesView(AdminView):
Expand All @@ -25,7 +26,13 @@ def get(self):
rows = self.sc_table.select(where={"complete": False})
ret = {"count": len(rows), "scheduled_changes": []}
for row in rows:
r = {}
# TODO: Probably need to return signoffs still required after
# that's been implemented. That + existing signoffs may end up
# in the same data structure.
r = {"signoffs": {}}
for signoff in self.sc_table.signoffs.select({"sc_id": row["sc_id"]}):
r["signoffs"][signoff["username"]] = signoff["role"]

for k, v in row.iteritems():
if k == "data_version":
r["sc_data_version"] = v
Expand Down Expand Up @@ -118,6 +125,44 @@ def _post(self, sc_id, transaction, changed_by):
return Response(status=200)


class SignoffsView(AdminView):
"""/scheduled_change/:namespace/:sc_id/signoffs"""

def __init__(self, namespace, table):
self.namespace = namespace
self.path = "/scheduled_changes/%s/:sc_id/signoffs" % namespace
self.signoffs_table = table.scheduled_changes.signoffs
super(SignoffsView, self).__init__()

def _post(self, sc_id, transaction, changed_by):
form = SignoffForm()
if not form.validate():
self.log.warning("Bad input: %s", form.errors)
return Response(status=400, response=json.dumps(form.errors))

try:
self.signoffs_table.insert(changed_by, transaction, sc_id=sc_id, **form.data)
except ValueError as e:
self.log.warning("Bad input: %s", e)
return Response(status=400, response=json.dumps({"exception": e.args}))

return Response(status=200)

def _delete(self, sc_id, transaction, changed_by):
where = {"sc_id": sc_id}
signoff = self.signoffs_table.select(where, transaction)
if not signoff:
return Response(status=404, response="{} has no signoff to revoke".format(changed_by))

form = Form(request.args)
if not form.validate():
self.log.warning("Bad input: %s", form.errors)
return Response(status=400, response=json.dumps(form.errors))

self.signoffs_table.delete(where, changed_by=changed_by, transaction=transaction)
return Response(status=200)


class ScheduledChangeHistoryView(HistoryAdminView):
"""/scheduled_changes/:namespace/revisions"""

Expand Down
60 changes: 60 additions & 0 deletions auslib/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,9 @@ def __init__(self, db, dialect, metadata, baseTable, conditions=("time", "uptake
Column("complete", Boolean, default=False),
)
self.conditions = ConditionsTable(db, dialect, metadata, table_name, conditions)
# Signoffs are configurable at runtime, which means that we always need
# a Signoffs table, even if it may not be used immediately.
self.signoffs = SignoffsTable(db, metadata, dialect, table_name)

# The primary key column(s) are used in construct "where" clauses for
# existing rows.
Expand Down Expand Up @@ -1115,6 +1118,51 @@ def mergeUpdate(self, old_row, what, changed_by, transaction=None):
self.log.debug("Merged %s into scheduled change '%s'", what, sc["sc_id"])


class SignoffsTable(AUSTable):

def __init__(self, db, metadata, dialect, baseName):
self.table = Table("{}_signoffs".format(baseName), metadata,
Column("sc_id", Integer, primary_key=True, autoincrement=False),
Column("username", String(100), primary_key=True),
Column("role", String(50), nullable=False),
)
# Because Signoffs cannot be modified, there's no possibility of an
# update race, so they do not need to be versioned.
super(SignoffsTable, self).__init__(db, dialect, versioned=False)

def insert(self, changed_by=None, transaction=None, dryrun=False, **columns):
if "sc_id" not in columns or "role" not in columns:
raise ValueError("sc_id and role must be provided when signing off")
if "username" in columns and columns["username"] != changed_by:
raise PermissionDeniedError("Cannot signoff on behalf of another user")
if not self.db.hasRole(changed_by, columns["role"], transaction=transaction):
raise PermissionDeniedError("{} cannot signoff with role '{}'".format(changed_by, columns["role"]))

existing_signoff = self.select({"sc_id": columns["sc_id"], "username": changed_by}, transaction)
if existing_signoff:
# It shouldn't be possible for there to be more than one signoff,
# so not iterating over this should be fine.
existing_signoff = existing_signoff[0]
if existing_signoff["role"] != columns["role"]:
raise PermissionDeniedError("Cannot signoff with a second role")
# Signoff already made under the same role, we don't need to do
# anything!
return

columns["username"] = changed_by
super(SignoffsTable, self).insert(changed_by=changed_by, transaction=transaction, dryrun=dryrun, **columns)

def update(self, where, what, changed_by=None, transaction=None, dryrun=False):
raise AttributeError("Signoffs cannot be modified (only granted and revoked)")

def delete(self, where, changed_by=None, transaction=None, dryrun=False):
for row in self.select(where, transaction):
if not self.db.hasRole(changed_by, row["role"], transaction=transaction) and not self.db.isAdmin(changed_by, transaction=transaction):
raise PermissionDeniedError("Cannot revoke a signoff made by someone in a group you do not belong to")

super(SignoffsTable, self).delete(where, changed_by=changed_by, transaction=transaction, dryrun=dryrun)


class Rules(AUSTable):

def __init__(self, db, metadata, dialect):
Expand Down Expand Up @@ -1865,6 +1913,9 @@ def getUserRoles(self, username, transaction=None):
res = self.user_roles.select(where=[self.user_roles.username == username], columns=[self.user_roles.role], distinct=True, transaction=transaction)
return [r["role"] for r in res]

def isAdmin(self, username, transaction=None):
return bool(self.getPermission(username, "admin", transaction))

def hasPermission(self, username, thing, action, product=None, transaction=None):
# Supporting product-wise admin permissions. If there are no options
# with admin, we assume that the user has admin access over all
Expand All @@ -1891,6 +1942,9 @@ def hasPermission(self, username, thing, action, product=None, transaction=None)

return True

def hasRole(self, username, role, transaction=None):
return role in self.getUserRoles(username, transaction)


class Dockerflow(AUSTable):
def __init__(self, db, metadata, dialect):
Expand Down Expand Up @@ -2084,9 +2138,15 @@ def setupChangeMonitors(self, relayhost, port, username, password, to_addr, from
use_tls)
self.releases.onUpdate = read_only_bleeter

def isAdmin(self, *args, **kwargs):
return self.permissions.isAdmin(*args, **kwargs)

def hasPermission(self, *args, **kwargs):
return self.permissions.hasPermission(*args, **kwargs)

def hasRole(self, *args, **kwargs):
return self.permissions.hasRole(*args, **kwargs)

def create(self, version=None):
# Migrate's "create" merely declares a database to be under its control,
# it doesn't actually create tables or upgrade it. So we need to call it
Expand Down
35 changes: 35 additions & 0 deletions auslib/migrate/versions/018_add_scheduled_rule_changes_signoffs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from sqlalchemy import Table, Column, Integer, String, MetaData, \
BigInteger


def upgrade(migrate_engine):
metadata = MetaData(bind=migrate_engine)

rules_scheduled_changes_signoffs = Table( # noqa - to hush pyflakes about this not being used.
"rules_scheduled_changes_signoffs", metadata,
Column("sc_id", Integer, primary_key=True, autoincrement=False),
Column("username", String(100), primary_key=True),
Column("role", String(50), nullable=False),
)

rules_scheduled_changes_signoffs_history = Table(
"rules_scheduled_changes_signoffs_history", metadata,
Column("change_id", Integer, primary_key=True, autoincrement=True),
Column("changed_by", String(100), nullable=False),
Column("sc_id", Integer, nullable=False, autoincrement=False),
Column("username", String(100), nullable=False),
Column("role", String(50)),
)
if migrate_engine.name == "mysql":
bigintType = BigInteger
elif migrate_engine.name == "sqlite":
bigintType = Integer
rules_scheduled_changes_signoffs_history.append_column(Column("timestamp", bigintType, nullable=False))
metadata.create_all()


def downgrade(migrate_engine):
metadata = MetaData(bind=migrate_engine)

Table("rules_scheduled_changes_signoffs", metadata, autoload=True).drop()
Table("rules_scheduled_changes_signoffs_history", metadata, autoload=True).drop()
1 change: 1 addition & 0 deletions auslib/test/admin/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def setUp(self):
dbo.permissions.t.insert().execute(permission='admin', username='billy',
options=json.dumps(dict(products=['a'])), data_version=1)
dbo.permissions.user_roles.t.insert().execute(username="bill", role="releng", data_version=1)
dbo.permissions.user_roles.t.insert().execute(username="bill", role="qa", data_version=1)
dbo.permissions.user_roles.t.insert().execute(username="bob", role="relman", data_version=1)
dbo.releases.t.insert().execute(
name='a', product='a', data=json.dumps(dict(name='a', hashFunction="sha512", schema_version=1)), data_version=1)
Expand Down
5 changes: 3 additions & 2 deletions auslib/test/admin/views/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ class TestUserRolesAPI_JSON(ViewTest):
def testGetRoles(self):
ret = self._get("/users/bill/roles")
self.assertStatusCode(ret, 200)
self.assertEquals(json.loads(ret.data), {"roles": ["releng"]})
got = set(json.loads(ret.data)["roles"])
self.assertEquals(got, set(["releng", "qa"]))

def testGetRolesMissingUser(self):
ret = self.client.get("/users/dean/roles")
Expand All @@ -192,7 +193,7 @@ def testGrantExistingRole(self):
self.assertStatusCode(ret, 200)
self.assertEquals(ret.data, json.dumps(dict(new_data_version=1)), ret.data)
got = dbo.permissions.user_roles.t.select().where(dbo.permissions.user_roles.username == "bill").execute().fetchall()
self.assertEquals(got, [("bill", "releng", 1)])
self.assertEquals(got, [("bill", "qa", 1), ("bill", "releng", 1)])

def testGrantRoleWithoutPermission(self):
ret = self._put("/users/emily/roles/relman", username="rory", data=dict(data_version=1))
Expand Down

0 comments on commit e9a7d5f

Please sign in to comment.