diff --git a/hc/accounts/migrations/0033_member_rw.py b/hc/accounts/migrations/0033_member_rw.py new file mode 100644 index 00000000..3c9cb290 --- /dev/null +++ b/hc/accounts/migrations/0033_member_rw.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-08-24 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0032_auto_20200819_0757'), + ] + + operations = [ + migrations.AddField( + model_name='member', + name='rw', + field=models.BooleanField(default=True), + ), + ] diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 9b3efa90..06f8d053 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -379,6 +379,7 @@ class Member(models.Model): user = models.ForeignKey(User, models.CASCADE, related_name="memberships") project = models.ForeignKey(Project, models.CASCADE) transfer_request_date = models.DateTimeField(null=True, blank=True) + rw = models.BooleanField(default=True) class Meta: constraints = [ diff --git a/hc/front/tests/test_details.py b/hc/front/tests/test_details.py index befe12d4..f2b0b3ee 100644 --- a/hc/front/tests/test_details.py +++ b/hc/front/tests/test_details.py @@ -45,3 +45,14 @@ class DetailsTestCase(BaseTestCase): self.client.login(username="alice@example.org", password="password") r = self.client.get(self.url + "?new") self.assertContains(r, "Your new check is ready!", status_code=200) + + def test_it_hides_actions_from_readonly_users(self): + self.bobs_membership.rw = False + self.bobs_membership.save() + + self.client.login(username="bob@example.org", password="password") + r = self.client.get(self.url) + + self.assertNotContains(r, "edit-name", status_code=200) + self.assertNotContains(r, "edit-desc") + self.assertNotContains(r, "Change Schedule") diff --git a/hc/front/tests/test_update_name.py b/hc/front/tests/test_update_name.py index de42b017..bd9e7677 100644 --- a/hc/front/tests/test_update_name.py +++ b/hc/front/tests/test_update_name.py @@ -41,6 +41,16 @@ class UpdateNameTestCase(BaseTestCase): r = self.client.post(self.url, data=payload) self.assertEqual(r.status_code, 404) + def test_it_requires_rw_access(self): + self.bobs_membership.rw = False + self.bobs_membership.save() + + payload = {"name": "Charlie Sent This"} + + self.client.login(username="bob@example.org", password="password") + r = self.client.post(self.url, data=payload) + self.assertEqual(r.status_code, 403) + def test_it_handles_bad_uuid(self): url = "/checks/not-uuid/name/" payload = {"name": "Alice Was Here"} diff --git a/hc/front/tests/test_update_timeout.py b/hc/front/tests/test_update_timeout.py index e28f6b20..f8984017 100644 --- a/hc/front/tests/test_update_timeout.py +++ b/hc/front/tests/test_update_timeout.py @@ -180,3 +180,13 @@ class UpdateTimeoutTestCase(BaseTestCase): self.client.login(username="bob@example.org", password="password") r = self.client.post(self.url, data=payload) self.assertRedirects(r, self.redirect_url) + + def test_it_requires_rw_access(self): + self.bobs_membership.rw = False + self.bobs_membership.save() + + payload = {"kind": "simple", "timeout": 3600, "grace": 60} + + self.client.login(username="bob@example.org", password="password") + r = self.client.post(self.url, data=payload) + self.assertEqual(r.status_code, 403) diff --git a/hc/front/views.py b/hc/front/views.py index 1754a0fa..931077b0 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -24,7 +24,7 @@ from django.urls import reverse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from hc.accounts.models import Project +from hc.accounts.models import Project, Member from hc.api.models import ( DEFAULT_GRACE, DEFAULT_TIMEOUT, @@ -85,15 +85,15 @@ def _get_check_for_user(request, code): assert request.user.is_authenticated - q = Check.objects - if not request.user.is_superuser: - project_ids = request.profile.projects().values("id") - q = q.filter(project_id__in=project_ids) + check = get_object_or_404(Check.objects.select_related("project"), code=code) + if request.user.is_superuser: + return check, True - try: - return q.get(code=code) - except Check.DoesNotExist: - raise Http404("not found") + if request.user.id == check.project.owner_id: + return check, True + + membership = get_object_or_404(Member, project=check.project, user=request.user) + return check, membership.rw def _get_channel_for_user(request, code): @@ -113,17 +113,17 @@ def _get_channel_for_user(request, code): def _get_project_for_user(request, project_code): - """ Return true if current user has access to the specified account. """ + """ Check access, return (project, rw) tuple. """ + project = get_object_or_404(Project, code=project_code) if request.user.is_superuser: - q = Project.objects - else: - q = request.profile.projects() + return project, True - try: - return q.get(code=project_code) - except Project.DoesNotExist: - raise Http404("not found") + if request.user.id == project.owner_id: + return project, True + + membership = get_object_or_404(Member, project=project, user=request.user) + return project, membership.rw def _refresh_last_active_date(profile): @@ -138,7 +138,7 @@ def _refresh_last_active_date(profile): @login_required def my_checks(request, code): _refresh_last_active_date(request.profile) - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.GET.get("sort") in VALID_SORT_VALUES: request.profile.sort = request.GET["sort"] @@ -183,6 +183,7 @@ def my_checks(request, code): ctx = { "page": "checks", + "rw": rw, "checks": checks, "channels": channels, "num_down": num_down, @@ -228,7 +229,7 @@ def status(request, code): @login_required @require_POST def switch_channel(request, code, channel_code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) channel = get_object_or_404(Channel, code=channel_code) if channel.project_id != check.project_id: @@ -321,7 +322,7 @@ def docs_cron(request): @require_POST @login_required def add_check(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if project.num_checks_available() <= 0: return HttpResponseBadRequest() @@ -337,7 +338,10 @@ def add_check(request, code): @require_POST @login_required def update_name(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) + if not rw: + return HttpResponseForbidden() + form = forms.NameTagsForm(request.POST) if form.is_valid(): check.name = form.cleaned_data["name"] @@ -354,7 +358,7 @@ def update_name(request, code): @require_POST @login_required def filtering_rules(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) form = forms.FilteringRulesForm(request.POST) if form.is_valid(): check.subject = form.cleaned_data["subject"] @@ -369,7 +373,9 @@ def filtering_rules(request, code): @require_POST @login_required def update_timeout(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) + if not rw: + return HttpResponseForbidden() kind = request.POST.get("kind") if kind == "simple": @@ -436,7 +442,7 @@ def cron_preview(request): @login_required def ping_details(request, code, n=None): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) q = Ping.objects.filter(owner=check) if n: q = q.filter(n=n) @@ -454,7 +460,7 @@ def ping_details(request, code, n=None): @require_POST @login_required def pause(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) check.status = "paused" check.last_start = None @@ -471,7 +477,7 @@ def pause(request, code): @require_POST @login_required def resume(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) check.status = "new" check.last_start = None @@ -485,7 +491,7 @@ def resume(request, code): @require_POST @login_required def remove_check(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) project = check.project check.delete() return redirect("hc-checks", project.code) @@ -518,7 +524,7 @@ def _get_events(check, limit): @login_required def log(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) limit = check.project.owner_profile.ping_log_limit ctx = { @@ -535,7 +541,7 @@ def log(request, code): @login_required def details(request, code): _refresh_last_active_date(request.profile) - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) channels = Channel.objects.filter(project=check.project) channels = list(channels.order_by("created")) @@ -549,6 +555,7 @@ def details(request, code): "page": "details", "project": check.project, "check": check, + "rw": rw, "channels": channels, "enabled_channels": list(check.channel_set.all()), "timezones": pytz.all_timezones, @@ -563,10 +570,10 @@ def details(request, code): @login_required def transfer(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) if request.method == "POST": - target_project = _get_project_for_user(request, request.POST["project"]) + target_project, rw = _get_project_for_user(request, request.POST["project"]) if target_project.num_checks_available() <= 0: return HttpResponseBadRequest() @@ -584,7 +591,7 @@ def transfer(request, code): @require_POST @login_required def copy(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) if check.project.num_checks_available() <= 0: return HttpResponseBadRequest() @@ -609,7 +616,7 @@ def copy(request, code): @login_required def status_single(request, code): - check = _get_check_for_user(request, code) + check, rw = _get_check_for_user(request, code) status = check.get_status() events = _get_events(check, 20) @@ -633,7 +640,7 @@ def status_single(request, code): @login_required def badges(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) tags = set() for check in Check.objects.filter(project=project): @@ -665,7 +672,7 @@ def badges(request, code): @login_required def channels(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": code = request.POST["channel"] @@ -830,7 +837,7 @@ def remove_channel(request, code): @login_required def add_email(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddEmailForm(request.POST) @@ -875,7 +882,7 @@ def add_email(request, code): @login_required def add_webhook(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.WebhookForm(request.POST) @@ -937,7 +944,7 @@ def edit_webhook(request, code): @require_setting("SHELL_ENABLED") @login_required def add_shell(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddShellForm(request.POST) if form.is_valid(): @@ -960,7 +967,7 @@ def add_shell(request, code): @login_required def add_pd(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddPdForm(request.POST) @@ -987,7 +994,7 @@ def pdc_help(request): @require_setting("PD_VENDOR_KEY") @login_required def add_pdc(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) state = token_urlsafe() callback = settings.SITE_ROOT + reverse( @@ -1008,7 +1015,7 @@ def add_pdc_complete(request, code, state): if "pd" not in request.session: return HttpResponseBadRequest() - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) session_state = request.session.pop("pd") if session_state != state: @@ -1033,7 +1040,7 @@ def add_pdc_complete(request, code, state): @login_required def add_pagertree(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddUrlForm(request.POST) @@ -1053,7 +1060,7 @@ def add_pagertree(request, code): @login_required def add_slack(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddUrlForm(request.POST) @@ -1084,7 +1091,7 @@ def slack_help(request): @require_setting("SLACK_CLIENT_ID") @login_required def add_slack_btn(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) state = token_urlsafe() authorize_url = "https://slack.com/oauth/v2/authorize?" + urlencode( @@ -1112,7 +1119,7 @@ def add_slack_complete(request): return HttpResponseForbidden() state, code = request.session.pop("add_slack") - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.GET.get("error") == "access_denied": messages.warning(request, "Slack setup was cancelled.") return redirect("hc-p-channels", project.code) @@ -1145,7 +1152,7 @@ def add_slack_complete(request): @login_required def add_mattermost(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddUrlForm(request.POST) @@ -1166,7 +1173,7 @@ def add_mattermost(request, code): @require_setting("PUSHBULLET_CLIENT_ID") @login_required def add_pushbullet(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) redirect_uri = settings.SITE_ROOT + reverse("hc-add-pushbullet-complete") state = token_urlsafe() @@ -1196,7 +1203,7 @@ def add_pushbullet_complete(request): return HttpResponseForbidden() state, code = request.session.pop("add_pushbullet") - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.GET.get("error") == "access_denied": messages.warning(request, "Pushbullet setup was cancelled.") @@ -1231,7 +1238,7 @@ def add_pushbullet_complete(request): @require_setting("DISCORD_CLIENT_ID") @login_required def add_discord(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) redirect_uri = settings.SITE_ROOT + reverse("hc-add-discord-complete") state = token_urlsafe() auth_url = "https://discordapp.com/api/oauth2/authorize?" + urlencode( @@ -1257,7 +1264,7 @@ def add_discord_complete(request): return HttpResponseForbidden() state, code = request.session.pop("add_discord") - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.GET.get("error") == "access_denied": messages.warning(request, "Discord setup was cancelled.") @@ -1300,7 +1307,7 @@ def pushover_help(request): @require_setting("PUSHOVER_API_TOKEN") @login_required def add_pushover(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": state = token_urlsafe() @@ -1365,7 +1372,7 @@ def add_pushover(request, code): @login_required def add_opsgenie(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddOpsGenieForm(request.POST) @@ -1386,7 +1393,7 @@ def add_opsgenie(request, code): @login_required def add_victorops(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddUrlForm(request.POST) @@ -1406,7 +1413,7 @@ def add_victorops(request, code): @login_required def add_zulip(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddZulipForm(request.POST) @@ -1474,7 +1481,7 @@ def add_telegram(request): return render(request, "bad_link.html") if request.method == "POST": - project = _get_project_for_user(request, request.POST.get("project")) + project, rw = _get_project_for_user(request, request.POST.get("project")) channel = Channel(project=project, kind="telegram") channel.value = json.dumps( {"id": chat_id, "type": chat_type, "name": chat_name} @@ -1500,7 +1507,7 @@ def add_telegram(request): @require_setting("TWILIO_AUTH") @login_required def add_sms(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddSmsForm(request.POST) if form.is_valid(): @@ -1526,7 +1533,7 @@ def add_sms(request, code): @require_setting("TWILIO_AUTH") @login_required def add_call(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddSmsForm(request.POST) if form.is_valid(): @@ -1552,7 +1559,7 @@ def add_call(request, code): @require_setting("TWILIO_USE_WHATSAPP") @login_required def add_whatsapp(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddSmsForm(request.POST) if form.is_valid(): @@ -1584,7 +1591,7 @@ def add_whatsapp(request, code): @require_setting("TRELLO_APP_KEY") @login_required def add_trello(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": channel = Channel(project=project, kind="trello") channel.value = request.POST["settings"] @@ -1617,7 +1624,7 @@ def add_trello(request, code): @require_setting("MATRIX_ACCESS_TOKEN") @login_required def add_matrix(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddMatrixForm(request.POST) if form.is_valid(): @@ -1649,7 +1656,7 @@ def add_matrix(request, code): @require_setting("APPRISE_ENABLED") @login_required def add_apprise(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddAppriseForm(request.POST) if form.is_valid(): @@ -1690,7 +1697,7 @@ def trello_settings(request): @login_required def add_msteams(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddUrlForm(request.POST) @@ -1710,7 +1717,7 @@ def add_msteams(request, code): @login_required def add_prometheus(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) ctx = {"page": "channels", "project": project} return render(request, "integrations/add_prometheus.html", ctx) @@ -1763,7 +1770,7 @@ def metrics(request, code, key): @login_required def add_spike(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddUrlForm(request.POST) @@ -1783,7 +1790,7 @@ def add_spike(request, code): @login_required def add_linenotify(request, code): - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if request.method == "POST": form = forms.AddLineNotifyForm(request.POST) diff --git a/hc/payments/views.py b/hc/payments/views.py index 367612a4..dcfac65c 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -20,7 +20,7 @@ def pricing(request, code=None): if not request.user.is_authenticated: raise Http404() - project = _get_project_for_user(request, code) + project, rw = _get_project_for_user(request, code) if project.owner != request.user: ctx = {"page": "pricing", "project": project} return render(request, "payments/pricing_not_owner.html", ctx) diff --git a/static/css/my_checks_desktop.css b/static/css/my_checks_desktop.css index bfaf04d5..8cc1a97c 100644 --- a/static/css/my_checks_desktop.css +++ b/static/css/my_checks_desktop.css @@ -40,9 +40,9 @@ padding: 6px; } -#checks-table tr:hover .my-checks-name, -#checks-table tr:hover .integrations, -#checks-table tr:hover .timeout-grace, +#checks-table.rw tr:hover .my-checks-name, +#checks-table.rw tr:hover .integrations, +#checks-table.rw tr:hover .timeout-grace, #checks-table tr:hover .last-ping { border: 1px dotted #AAA; cursor: pointer; diff --git a/static/js/checks.js b/static/js/checks.js index a0323f94..9413d135 100644 --- a/static/js/checks.js +++ b/static/js/checks.js @@ -1,7 +1,7 @@ $(function () { var base = document.getElementById("base-url").getAttribute("href").slice(0, -1); - $(".my-checks-name").click(function() { + $(".rw .my-checks-name").click(function() { var code = $(this).closest("tr.checks-row").attr("id"); var url = base + "/checks/" + code + "/name/"; @@ -27,7 +27,7 @@ $(function () { } }); - $(".integrations").on("click", "span", function() { + $(".rw .integrations").on("click", "span", function() { var isOff = $(this).toggleClass("off").hasClass("off"); var token = $('input[name=csrfmiddlewaretoken]').val(); diff --git a/static/js/details.js b/static/js/details.js index 3992ffc5..44b9efea 100644 --- a/static/js/details.js +++ b/static/js/details.js @@ -72,7 +72,7 @@ $(function () { }); }); - var statusUrl = document.getElementById("edit-timeout").dataset.statusUrl; + var statusUrl = document.getElementById("events").dataset.statusUrl; var lastStatusText = ""; var lastUpdated = ""; adaptiveSetInterval(function() { diff --git a/static/js/update-timeout-modal.js b/static/js/update-timeout-modal.js index c1efe85b..ee997622 100644 --- a/static/js/update-timeout-modal.js +++ b/static/js/update-timeout-modal.js @@ -1,7 +1,7 @@ $(function () { var base = document.getElementById("base-url").getAttribute("href").slice(0, -1); - $(".timeout-grace").click(function() { + $(".rw .timeout-grace").click(function() { var code = $(this).closest("tr.checks-row").attr("id"); if (!code) { code = this.dataset.code; diff --git a/templates/front/details.html b/templates/front/details.html index 093dd09a..e222d3d8 100644 --- a/templates/front/details.html +++ b/templates/front/details.html @@ -41,7 +41,9 @@