From 7dc65d62ca0867416b5576ff8f49faf10194ac28 Mon Sep 17 00:00:00 2001 From: Miroslav Suchý Date: Jun 11 2018 08:45:10 +0000 Subject: Merge #308 `GDPR compliance` --- diff --git a/frontend/copr-frontend.spec b/frontend/copr-frontend.spec index 9a4ad2d..11b26da 100644 --- a/frontend/copr-frontend.spec +++ b/frontend/copr-frontend.spec @@ -16,7 +16,7 @@ %staticdir/copr_logo.png \ %staticdir/css/style-overwrite.css \ %templatedir/project_info.html \ -%templatedir/user_info.html \ +%templatedir/user_meta.html \ %templatedir/welcome.html %global devel_files \ @@ -87,6 +87,7 @@ BuildRequires: python3-CommonMark BuildRequires: python3-pygments BuildRequires: python3-flask-whooshee BuildRequires: python3-modulemd +BuildRequires: python3-simplejson BuildRequires: redis %endif @@ -122,6 +123,7 @@ Requires: python3-pygments Requires: python3-CommonMark Requires: python3-psycopg2 Requires: python3-zmq +Requires: python3-simplejson Requires: xstatic-patternfly-common Requires: js-jquery1 Requires: xstatic-jquery-ui-common diff --git a/frontend/coprs_frontend/coprs/__init__.py b/frontend/coprs_frontend/coprs/__init__.py index 1d089b7..4c3bffb 100644 --- a/frontend/coprs_frontend/coprs/__init__.py +++ b/frontend/coprs_frontend/coprs/__init__.py @@ -65,6 +65,8 @@ from coprs.views import tmp_ns from coprs.views.tmp_ns import tmp_general from coprs.views.groups_ns import groups_ns from coprs.views.groups_ns import groups_general +from coprs.views.user_ns import user_ns +from coprs.views.user_ns import user_general from coprs.views.webhooks_ns import webhooks_ns from coprs.views.webhooks_ns import webhooks_general @@ -83,6 +85,7 @@ app.register_blueprint(recent_ns.recent_ns) app.register_blueprint(stats_receiver.stats_rcv_ns) app.register_blueprint(tmp_ns.tmp_ns) app.register_blueprint(groups_ns) +app.register_blueprint(user_ns) app.register_blueprint(webhooks_ns) app.add_url_rule("/", "coprs_ns.coprs_show", coprs_general.coprs_show) diff --git a/frontend/coprs_frontend/coprs/logic/users_logic.py b/frontend/coprs_frontend/coprs/logic/users_logic.py index 1f23b17..7c78fdf 100644 --- a/frontend/coprs_frontend/coprs/logic/users_logic.py +++ b/frontend/coprs_frontend/coprs/logic/users_logic.py @@ -1,7 +1,12 @@ +import json +import simplejson from coprs import exceptions +from flask import url_for from coprs import app, db from coprs.models import User, Group +from coprs.helpers import copr_url +from sqlalchemy import update class UsersLogic(object): @@ -102,3 +107,69 @@ class UsersLogic(object): return fas_group in app.config["BLACKLISTED_GROUPS"] else: return False + + @classmethod + def delete_user_data(cls, fas_name): + query = update(User).where(User.username==fas_name).\ + values( + timezone=None, + proven=False, + admin=False, + proxy=False, + api_login='', + api_token='', + api_token_expiration='1970-01-01', + openid_groups=None + ) + db.engine.connect().execute(query) + + +class UserDataDumper(object): + def __init__(self, user): + self.user = user + + def dumps(self, pretty=False): + if pretty: + return simplejson.dumps(self.data, indent=2) + return json.dumps(self.data) + + @property + def data(self): + data = self.user_information + data["groups"] = self.groups + data["projects"] = self.projects + data["builds"] = self.builds + return data + + @property + def user_information(self): + return { + "username": self.user.name, + "email": self.user.mail, + "timezone": self.user.timezone, + "api_login": self.user.api_login, + "api_token": self.user.api_token, + "api_token_expiration": self.user.api_token_expiration.strftime("%b %d %Y %H:%M:%S"), + "gravatar": self.user.gravatar_url, + } + + @property + def groups(self): + return [{"name": g.name, + "url": url_for("groups_ns.list_projects_by_group", group_name=g.name, _external=True)} + for g in self.user.user_groups] + + @property + def projects(self): + # @FIXME We get into circular import when this import is on module-level + from coprs.logic.coprs_logic import CoprsLogic + return [{"full_name": p.full_name, + "url": copr_url("coprs_ns.copr_detail", p, _external=True)} + for p in CoprsLogic.filter_by_user_name(CoprsLogic.get_multiple(), self.user.name)] + + @property + def builds(self): + return [{"id": b.id, + "project": b.copr.full_name, + "url": copr_url("coprs_ns.copr_build", b.copr, build_id=b.id, _external=True)} + for b in self.user.builds] diff --git a/frontend/coprs_frontend/coprs/templates/coprs/show.html b/frontend/coprs_frontend/coprs/templates/coprs/show.html index 5a63ee6..3198f12 100644 --- a/frontend/coprs_frontend/coprs/templates/coprs/show.html +++ b/frontend/coprs_frontend/coprs/templates/coprs/show.html @@ -7,6 +7,7 @@ {% block body %}
+ {% block content %} {% block show_top %} {% endblock %}
@@ -39,6 +40,7 @@
{{ render_pagination(request, paginator) }} + {% endblock %}

diff --git a/frontend/coprs_frontend/coprs/templates/coprs/show/user.html b/frontend/coprs_frontend/coprs/templates/coprs/show/user.html index 2a0f928..a25eba4 100644 --- a/frontend/coprs_frontend/coprs/templates/coprs/show/user.html +++ b/frontend/coprs_frontend/coprs/templates/coprs/show/user.html @@ -29,7 +29,12 @@ {% endif %} - {% include "user_info.html" %} + {% if g.user %} + | User Info + {% endif %} + + + {% include "user_meta.html" %}

diff --git a/frontend/coprs_frontend/coprs/templates/layout.html b/frontend/coprs_frontend/coprs/templates/layout.html index 0a818a5..71795d6 100644 --- a/frontend/coprs_frontend/coprs/templates/layout.html +++ b/frontend/coprs_frontend/coprs/templates/layout.html @@ -115,6 +115,7 @@
  • Home
  • Task Queue
  • API
  • +
  • GDPR
  • diff --git a/frontend/coprs_frontend/coprs/templates/user_info.html b/frontend/coprs_frontend/coprs/templates/user_info.html index 4f91dd6..188cab3 100644 --- a/frontend/coprs_frontend/coprs/templates/user_info.html +++ b/frontend/coprs_frontend/coprs/templates/user_info.html @@ -1,4 +1,50 @@ -| - - Fedora badges +{% extends "coprs/show.html" %} +{% block title %}User Info{% endblock %} +{% block header %}User Info{% endblock %} +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    User Info

    + +

    + According to the GDPR (General Data Protection Regulation 2016/679 of the European Union), users have right to see + all the data, that service stores about them, and also right to delete them. Read more about your + rights and privacy in the Fedora Project. +

    + + +

    Obtaining data

    +

    + It is possible to download a JSON file with a dump of all the information that Copr knows about you (e.g. list of + your projects, your builds, contact information, API tokens, etc). +

    + + + + +

    Delete data

    +

    + This will delete the information Copr knows about you, such as timezone and API tokens. However, your username and + email cannot be deleted as they are tied to the FAS account. Moreover, if you wish to delete your builds/projects, + you have to do so manually (possibly using the data acquired above). +

    +

    + You can still log in at any later time. However, you will have to create a new API token. +

    + + + +{% endblock %} diff --git a/frontend/coprs_frontend/coprs/templates/user_meta.html b/frontend/coprs_frontend/coprs/templates/user_meta.html new file mode 100644 index 0000000..4f91dd6 --- /dev/null +++ b/frontend/coprs_frontend/coprs/templates/user_meta.html @@ -0,0 +1,4 @@ +| + + Fedora badges + diff --git a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py index f002cc6..600cabd 100644 --- a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py +++ b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py @@ -33,7 +33,6 @@ from coprs.exceptions import ObjectNotFound from coprs.logic.coprs_logic import CoprsLogic from coprs.logic.packages_logic import PackagesLogic from coprs.logic.stat_logic import CounterStatLogic -from coprs.logic.users_logic import UsersLogic from coprs.logic.modules_logic import ModulesLogic, ModulemdGenerator, ModuleBuildFacade from coprs.rmodels import TimedStatEvents diff --git a/frontend/coprs_frontend/coprs/views/user_ns/__init__.py b/frontend/coprs_frontend/coprs/views/user_ns/__init__.py new file mode 100644 index 0000000..031e163 --- /dev/null +++ b/frontend/coprs_frontend/coprs/views/user_ns/__init__.py @@ -0,0 +1,4 @@ +import flask + + +user_ns = flask.Blueprint("user_ns", __name__, url_prefix="/user") diff --git a/frontend/coprs_frontend/coprs/views/user_ns/user_general.py b/frontend/coprs_frontend/coprs/views/user_ns/user_general.py new file mode 100644 index 0000000..1c4d6aa --- /dev/null +++ b/frontend/coprs_frontend/coprs/views/user_ns/user_general.py @@ -0,0 +1,39 @@ +import flask +from . import user_ns +from coprs.views.misc import login_required +from coprs.logic.users_logic import UsersLogic, UserDataDumper +from coprs.logic.builds_logic import BuildsLogic +from coprs.logic.complex_logic import ComplexLogic + + +def render_user_info(user): + graph = BuildsLogic.get_running_tasks_from_last_day() + return flask.render_template("user_info.html", + user=user, + tasks_info=ComplexLogic.get_queue_sizes(), + graph=graph) + + +@user_ns.route("/info") +@login_required +def user_info(): + return render_user_info(flask.g.user) + + +@user_ns.route("/info/download") +@login_required +def user_info_download(): + user = flask.g.user + dumper = UserDataDumper(user) + response = flask.make_response(dumper.dumps(pretty=True)) + response.mimetype = "application/json" + response.headers["Content-Disposition"] = "attachment; filename={0}.json".format(user.name) + return response + + +@user_ns.route("/delete") +@login_required +def delete_data(): + UsersLogic.delete_user_data(flask.g.user.username) + flask.flash("Your data were successfully deleted.") + return render_user_info(flask.g.user) diff --git a/frontend/coprs_frontend/manage.py b/frontend/coprs_frontend/manage.py index a2d0ad7..9f6290c 100755 --- a/frontend/coprs_frontend/manage.py +++ b/frontend/coprs_frontend/manage.py @@ -15,7 +15,7 @@ from coprs import app from coprs import db from coprs import exceptions from coprs import models -from coprs.logic import coprs_logic, packages_logic, actions_logic, builds_logic +from coprs.logic import coprs_logic, packages_logic, actions_logic, builds_logic, users_logic from coprs.views.misc import create_user_wrapper from coprs.whoosheers import CoprWhoosheer from sqlalchemy import or_ @@ -332,6 +332,21 @@ class AddUserCommand(Command): ) +class DumpUserCommand(Command): + + def run(self, username): + user = models.User.query.filter(models.User.username == username).first() + if not user: + print("There is no user named {0}.".format(username)) + return 1 + dumper = users_logic.UserDataDumper(user) + print(dumper.dumps(pretty=True)) + + option_list = ( + Option("username"), + ) + + class AlterUserCommand(Command): def run(self, name, **kwargs): @@ -457,6 +472,7 @@ manager.add_command("display_chroots", DisplayChrootsCommand()) manager.add_command("drop_chroot", DropChrootCommand()) manager.add_command("alter_user", AlterUserCommand()) manager.add_command("add_user", AddUserCommand()) +manager.add_command("dump_user", DumpUserCommand()) manager.add_command("fail_build", FailBuildCommand()) manager.add_command("update_indexes", UpdateIndexesCommand()) manager.add_command("update_indexes_quick", UpdateIndexesQuickCommand()) diff --git a/frontend/coprs_frontend/run/copr-gdpr-sar.sh b/frontend/coprs_frontend/run/copr-gdpr-sar.sh new file mode 100755 index 0000000..934e0f2 --- /dev/null +++ b/frontend/coprs_frontend/run/copr-gdpr-sar.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Purpose of this script is to be remotely executed from batcave01 +# to collect user SAR data +# Read more: http://fedora-infra-docs.readthedocs.io/en/latest/sysadmin-guide/sops/gdpr_sar.html +# Playbook: https://infrastructure.fedoraproject.org/cgit/ansible.git/tree/playbooks/manual/gdpr/sar.yml +# Usage: SAR_USERNAME=someusername copr-gdpr-sar.sh +copr-frontend dump_user $SAR_USERNAME diff --git a/frontend/coprs_frontend/tests/test_logic/test_users_logic.py b/frontend/coprs_frontend/tests/test_logic/test_users_logic.py new file mode 100644 index 0000000..4308714 --- /dev/null +++ b/frontend/coprs_frontend/tests/test_logic/test_users_logic.py @@ -0,0 +1,54 @@ +import flask +import json +from coprs import app +from coprs.logic.users_logic import UsersLogic, UserDataDumper +from tests.coprs_test_case import CoprsTestCase + + +app.config["SERVER_NAME"] = "localhost" + + +class TestUserDataDumper(CoprsTestCase): + + def test_user_information(self, f_users, f_fas_groups, f_coprs, f_db): + dumper = UserDataDumper(self.u1) + data = dumper.user_information + assert data["username"] == "user1" + assert data["email"] == "user1@foo.bar" + assert data["timezone"] is None + assert data["api_login"] == "abc" + assert data["api_token"] == "abc" + assert data["api_token_expiration"] == "Jan 01 2000 00:00:00" + assert data["gravatar"].startswith("https://seccdn.libravatar.org/avatar/") + + def test_projects(self, f_users, f_coprs, f_db): + with app.app_context(): + dumper = UserDataDumper(self.u1) + projects = dumper.projects + assert [p["full_name"] for p in projects] == ["user1/foocopr"] + + def test_builds(self, f_users, f_coprs, f_builds, f_db): + with app.app_context(): + dumper = UserDataDumper(self.u1) + builds = dumper.builds + assert len(builds) == 1 + assert builds[0]["id"] == 1 + assert builds[0]["project"] == "user1/foocopr" + + def test_data(self, f_users, f_fas_groups, f_coprs, f_db): + with app.app_context(): + dumper = UserDataDumper(self.u1) + data = dumper.data + assert "username" in data + assert type(data["groups"]) == list + assert type(data["projects"]) == list + assert type(data["builds"]) == list + + def test_dumps(self, f_users, f_fas_groups, f_coprs, f_db): + with app.app_context(): + dumper = UserDataDumper(self.u1) + output = dumper.dumps() + assert type(output) == str + data = json.loads(output) + assert "username" in data + assert "projects" in data