From ed7af4fc8253be7af251f3faa32fb982b5e16010 Mon Sep 17 00:00:00 2001 From: Evan Carlin Date: Wed, 8 Jan 2025 17:53:29 +0000 Subject: [PATCH 1/9] Fix #7424: make all of Sirepo moderated A trial is now required to access all of Sirepo. Every new user is moderated. All existing users will be give 30 days access. After that a paid plan will be required. 30 days after this is deployed (after all non-paid users are expired) we can remove moderating for Jupyterhub. Just the moderation for Sirepo will be enough. --- etc/run.sh | 1 - sirepo/api_auth.py | 5 +- sirepo/api_perm.py | 4 +- sirepo/auth/__init__.py | 25 ++++++--- sirepo/auth_db/user.py | 38 ++++++++----- sirepo/auth_role.py | 8 ++- sirepo/auth_role_moderation.py | 56 +++++++++++++++---- sirepo/db_upgrade.py | 22 ++++++++ sirepo/feature_config.py | 1 + sirepo/job_api.py | 24 ++++---- .../auth_role_moderation/approve_email.jinja | 3 +- .../auth_role_moderation/clarify_email.jinja | 1 - .../moderation_email.jinja | 1 + .../static/html/http-plan-required.html | 8 +++ .../static/js/sirepo-components.js | 21 +++++-- sirepo/package_data/static/js/sirepo.js | 7 +-- .../static/json/schema-common.json | 12 ++++ sirepo/pkcli/roles.py | 41 ++++++++++++-- sirepo/reply.py | 9 ++- sirepo/server.py | 43 ++++++++------ sirepo/sim_api/jupyterhublogin.py | 4 +- sirepo/sim_data/__init__.py | 2 +- sirepo/sim_oauth/flash.py | 2 +- sirepo/uri_router.py | 7 +-- sirepo/util.py | 6 ++ tests/plan_trial_test.py | 49 ++++++++++++++++ 26 files changed, 303 insertions(+), 97 deletions(-) create mode 100644 sirepo/package_data/static/html/http-plan-required.html create mode 100644 tests/plan_trial_test.py diff --git a/etc/run.sh b/etc/run.sh index bf2bb67d48..bfb1e8940a 100644 --- a/etc/run.sh +++ b/etc/run.sh @@ -26,7 +26,6 @@ _env_mail_smtp() { _env_moderate() { declare sim_type=$1 export SIREPO_FEATURE_CONFIG_MODERATED_SIM_TYPES=$sim_type - export SIREPO_AUTH_ROLE_MODERATION_MODERATOR_EMAIL=$USER+moderator@localhost.localdomain _msg "Moderated sim_type=$sim_type" _setup_smtp _env_common diff --git a/sirepo/api_auth.py b/sirepo/api_auth.py index ab484d77d9..d8f83f8dbf 100644 --- a/sirepo/api_auth.py +++ b/sirepo/api_auth.py @@ -32,12 +32,15 @@ def check_api_call(qcall, func): a.ALLOW_SIM_TYPELESS_REQUIRE_EMAIL_USER, a.REQUIRE_COOKIE_SENTINEL, a.REQUIRE_USER, + a.REQUIRE_PLAN, a.REQUIRE_ADM, a.REQUIRE_PREMIUM, ): if not qcall.cookie.has_sentinel(): raise sirepo.util.SRException("missingCookies", None) - if expect == a.REQUIRE_USER: + if expect == a.REQUIRE_PLAN: + qcall.auth.require_plan() + elif expect == a.REQUIRE_USER: qcall.auth.require_user() elif expect == a.ALLOW_SIM_TYPELESS_REQUIRE_EMAIL_USER: qcall.auth.require_email_user() diff --git a/sirepo/api_perm.py b/sirepo/api_perm.py index 1c28eb750b..9a932a6c59 100644 --- a/sirepo/api_perm.py +++ b/sirepo/api_perm.py @@ -32,7 +32,9 @@ class APIPerm(aenum.Flag): REQUIRE_USER = aenum.auto() #: only usable on internal test systems INTERNAL_TEST = aenum.auto() - #: a user with a a premium subscription is required + #: a user with an active plan (any type) is required + REQUIRE_PLAN = aenum.auto() + #: a user with a a premium plan is required REQUIRE_PREMIUM = aenum.auto() diff --git a/sirepo/auth/__init__.py b/sirepo/auth/__init__.py index ffb7877963..dffeb6dc78 100644 --- a/sirepo/auth/__init__.py +++ b/sirepo/auth/__init__.py @@ -172,11 +172,9 @@ def check_sim_type_role(self, sim_type, force_sim_type_required_for_api=False): return u = self.logged_in_user() r = sirepo.auth_role.for_sim_type(t) - if self.qcall.auth_db.model("UserRole").has_role( - role=r - ) and not self.qcall.auth_db.model("UserRole").is_expired(role=r): + if self.qcall.auth_db.model("UserRole").has_active_role(role=r): return - elif r in sirepo.auth_role.for_proprietary_oauth_sim_types(): + if r in sirepo.auth_role.for_proprietary_oauth_sim_types(): oauth.raise_authorize_redirect(self.qcall, sirepo.auth_role.sim_type(r)) if r in sirepo.auth_role.for_moderated_sim_types(): auth_role_moderation.raise_control_for_user(self.qcall, u, r) @@ -184,7 +182,7 @@ def check_sim_type_role(self, sim_type, force_sim_type_required_for_api=False): def _assert_role_user(self): u = self.logged_in_user() - if not self.qcall.auth_db.model("UserRole").has_role( + if not self.qcall.auth_db.model("UserRole").has_active_role( role=sirepo.auth_role.ROLE_USER, ): raise sirepo.util.Forbidden( @@ -274,7 +272,7 @@ def is_logged_in(self, state=None): return s in (_STATE_COMPLETE_REGISTRATION, _STATE_LOGGED_IN) def is_premium_user(self): - return self.qcall.auth_db.model("UserRole").has_role( + return self.qcall.auth_db.model("UserRole").has_active_role( role=sirepo.auth_role.ROLE_PAYMENT_PLAN_PREMIUM, ) @@ -493,7 +491,7 @@ def parse_display_name(self, value): def require_adm(self): u = self.require_user() - if not self.qcall.auth_db.model("UserRole").has_role( + if not self.qcall.auth_db.model("UserRole").has_active_role( role=sirepo.auth_role.ROLE_ADM, ): raise sirepo.util.Forbidden( @@ -515,9 +513,20 @@ def require_email_user(self): if m != METHOD_EMAIL: raise sirepo.util.Forbidden(f"method={m} is not email for uid={i}") + def require_plan(self): + from sirepo import auth_role_moderation + + u = self.require_user() + for r in sirepo.auth_role.for_plans(): + if self.qcall.auth_db.model("UserRole").has_active_role(r): + return + auth_role_moderation.raise_control_for_user( + self.qcall, u, sirepo.auth_role.ROLE_TRIAL + ) + def require_premium(self): if not self.is_premium_user(): - raise sirepo.util.Forbidden(f"not premium user") + raise sirepo.util.Forbidden("not premium user") def require_user(self): """Asserts whether user is logged in diff --git a/sirepo/auth_db/user.py b/sirepo/auth_db/user.py index 15c16b357c..d5347c38dd 100644 --- a/sirepo/auth_db/user.py +++ b/sirepo/auth_db/user.py @@ -36,9 +36,9 @@ def add_roles(self, roles, expiration=None): u = self.logged_in_user() for r in roles: try: - # Check here, because sqlite doesn't through IntegrityErrors + # Check here, because sqlite doesn't throw IntegrityErrors # at the point of the new() operation. - if not self.has_role(r, uid=u): + if not self._has_role(r, uid=u): self.new(uid=u, role=r, expiration=expiration).save() except sqlalchemy.exc.IntegrityError: # role already exists @@ -46,7 +46,7 @@ def add_roles(self, roles, expiration=None): sim_data.audit_proprietary_lib_files(qcall=self.auth_db.qcall) def add_role_or_update_expiration(self, role, expiration): - if not self.has_role(role): + if not self._has_role(role): self.add_roles(roles=[role], expiration=expiration) return r = self.search_by(uid=self.logged_in_user(), role=role) @@ -71,19 +71,19 @@ def delete_roles(self, roles, uid=None): def get_roles(self): return self.search_all_for_column("role", uid=self.logged_in_user()) - def has_role(self, role, uid=None): - return bool( - self.unchecked_search_by(uid=uid or self.logged_in_user(), role=role) - ) + def get_roles_and_expiration(self): + return [ + PKDict(role=r.role, expiration=r.expiration) + for r in self.query().filter_by(uid=self.logged_in_user()) + ] - def is_expired(self, role): - u = self.logged_in_user() - assert self.has_role(role=role), f"No role for uid={u} and role={role}" - r = self.search_by(uid=u, role=role) - if not r.expiration: - # Roles with no expiration can't expire - return False - return r.expiration < sirepo.srtime.utc_now() + def has_active_role(self, role, uid=None): + r = self._has_role(role, uid=uid) + return r and not self._is_expired_role(r) + + def has_expired_role(self, role): + r = self._has_role(role) + return r and self._is_expired_role(r) def uids_of_paid_users(self): return self.uids_with_roles(sirepo.auth_role.PAID_USER_ROLES) @@ -103,6 +103,14 @@ def uids_with_roles(self, roles): .all() ] + def _has_role(self, role, uid=None): + return self.unchecked_search_by(uid=uid or self.logged_in_user(), role=role) + + def _is_expired_role(self, role_record): + return ( + role_record.expiration and role_record.expiration < sirepo.srtime.utc_now() + ) + class UserRoleModeration(sirepo.auth_db.UserDbBase): __tablename__ = "user_role_moderation_t" diff --git a/sirepo/auth_role.py b/sirepo/auth_role.py index 5750e2f7d3..deca602980 100644 --- a/sirepo/auth_role.py +++ b/sirepo/auth_role.py @@ -10,8 +10,9 @@ import sirepo.feature_config ROLE_ADM = "adm" -ROLE_USER = "user" ROLE_PAYMENT_PLAN_PREMIUM = "premium" +ROLE_TRIAL = "trial" +ROLE_USER = "user" PAID_USER_ROLES = (ROLE_PAYMENT_PLAN_PREMIUM,) _SIM_TYPE_ROLE_PREFIX = "sim_type_" @@ -49,6 +50,10 @@ def for_proprietary_oauth_sim_types(): ] +def for_plans(): + return {ROLE_TRIAL, *PAID_USER_ROLES} + + def for_sim_type(sim_type): return _SIM_TYPE_ROLE_PREFIX + sim_type @@ -59,6 +64,7 @@ def get_all(): ] + [ ROLE_ADM, ROLE_PAYMENT_PLAN_PREMIUM, + ROLE_TRIAL, ROLE_USER, ] diff --git a/sirepo/auth_role_moderation.py b/sirepo/auth_role_moderation.py index f10399dfdd..0845380c56 100644 --- a/sirepo/auth_role_moderation.py +++ b/sirepo/auth_role_moderation.py @@ -5,18 +5,20 @@ :license: http://www.apache.org/licenses/LICENSE-2.0.html """ from pykern import pkconfig -from pykern.pkdebug import pkdexc, pkdp, pkdlog -from pykern.pkcollections import PKDict from pykern import pkjinja +from pykern.pkcollections import PKDict +from pykern.pkdebug import pkdexc, pkdp, pkdlog import datetime -import sirepo.quest +import getpass import sirepo.auth_role import sirepo.feature_config +import sirepo.quest import sirepo.simulation_db import sirepo.smtp import sirepo.uri import sirepo.uri_router import sqlalchemy +import sqlalchemy.exc _STATUS_TO_SUBJECT = PKDict( approve="Access Request Approved", @@ -45,11 +47,13 @@ async def api_admModerate(self): def _send_moderation_status_email(info): sirepo.smtp.send( recipient=info.user_name, - subject=f"Sirepo {info.app_name}: {_STATUS_TO_SUBJECT[info.status]}", + subject=f"Sirepo {info.role_friendly_name}: {_STATUS_TO_SUBJECT[info.status]}", body=pkjinja.render_resource( f"auth_role_moderation/{info.status}_email", PKDict( + additional_text=info.get("additional_text"), app_name=info.app_name, + role_friendly_name=info.role_friendly_name, display_name=info.display_name, link=self.absolute_uri( self.uri_for_app_root( @@ -62,7 +66,9 @@ def _send_moderation_status_email(info): def _set_moderation_status(info): if info.status == "approve": - self.auth_db.model("UserRole").add_roles(roles=[info.role]) + self.auth_db.model("UserRole").add_roles( + roles=[info.role], expiration=info.get("expiration") + ) self.auth_db.model("UserRoleModeration").set_status( role=info.role, status=info.status, @@ -88,9 +94,20 @@ def _set_moderation_status(info): req.req_data.role, ) p = PKDict( - app_name=sirepo.simulation_db.SCHEMA_COMMON.appInfo[ + role_friendly_name="Trial", + app_name="Sirepo", + expiration=datetime.datetime.now() + + datetime.timedelta( + days=sirepo.feature_config.cfg().trial_expiration_days + ), + additional_text=f"Your trial is active for {sirepo.feature_config.cfg().trial_expiration_days} days.", + ) + if not i.role == sirepo.auth_role.ROLE_TRIAL: + a = sirepo.simulation_db.SCHEMA_COMMON.appInfo[ sirepo.auth_role.sim_type(i.role) - ].longName, + ].longName + p = PKDict(role_friendly_name=a, app_name=a) + p.pkupdate( role=i.role, status=req.req_data.status, moderator_uid=self.auth.logged_in_user(), @@ -153,9 +170,18 @@ def _send_request_email(info): req = self.parse_post() u = self.auth.logged_in_user() - r = sirepo.auth_role.for_sim_type(req.type) - if self.auth_db.model("UserRole").has_role(role=r): + r = sirepo.auth_role.ROLE_TRIAL + # SECURITY: type has been validated to be a known sim type. If it is + # not in moderated_sim_types then we know the user is just requesting + # access to start using Sirepo (ROLE_TRIAL). + if req.type in sirepo.feature_config.cfg().moderated_sim_types: + r = sirepo.auth_role.for_sim_type(req.type) + if self.auth_db.model("UserRole").has_active_role(role=r): raise sirepo.util.Redirect(sirepo.uri.local_route(req.type)) + if self.auth_db.model("UserRole").has_expired_role(role=r): + raise AssertionError( + f"uid={u} trying to request moderation for expired role={r}" + ) try: self.auth_db.model( "UserRoleModeration", @@ -183,7 +209,7 @@ def _send_request_email(info): email_addr=self.auth.logged_in_user_name(), link=l, reason=req.req_data.reason, - role=sirepo.auth_role.for_sim_type(req.type), + role=r, sim_type=req.type, uid=u, ).pkupdate(self.user_agent_headers()) @@ -200,6 +226,10 @@ def _datetime_to_str(rows): def raise_control_for_user(qcall, uid, role): + if qcall.auth_db.model("UserRole").has_expired_role(role): + if role == sirepo.auth_role.ROLE_TRIAL: + raise sirepo.util.PlanRequired(f"uid={uid} role={role} expired") + raise sirepo.util.Forbidden(f"uid={uid} role={role} expired") s = qcall.auth_db.model("UserRoleModeration").get_status(uid=uid, role=role) if s in _ACTIVE: raise sirepo.util.SRException("moderationPending", None) @@ -214,8 +244,10 @@ def init_apis(*args, **kwargs): global _cfg _cfg = pkconfig.init( - moderator_email=pkconfig.Required( - str, "The email address to send moderation emails to" + moderator_email=pkconfig.RequiredUnlessDev( + f"{getpass.getuser()}+moderator@localhost.localdomain", + str, + "The email address to send moderation emails to", ), ) x = frozenset(_STATUS_TO_SUBJECT.keys()) diff --git a/sirepo/db_upgrade.py b/sirepo/db_upgrade.py index a7e3917b00..275a2b51ad 100644 --- a/sirepo/db_upgrade.py +++ b/sirepo/db_upgrade.py @@ -8,9 +8,11 @@ from pykern.pkcollections import PKDict from pykern.pkdebug import pkdp, pkdlog, pkdexc import contextlib +import datetime import shutil import sirepo.auth_db import sirepo.auth_role +import sirepo.feature_config import sirepo.file_lock import sirepo.job import sirepo.quest @@ -102,6 +104,26 @@ def _20240524_add_role_user(qcall): ) +def _20250114_add_role_trial(qcall): + """Give all existing users a trial with expiration""" + import sirepo.pkcli.roles + import sirepo.auth_role + + for u in qcall.auth_db.all_uids(): + sirepo.pkcli.roles.add( + u, + sirepo.auth_role.ROLE_TRIAL, + expiration=int( + ( + datetime.datetime.now() + + datetime.timedelta( + days=sirepo.feature_config.cfg().trial_expiration_days + ) + ).timestamp() + ), + ) + + @contextlib.contextmanager def _backup_db_and_prevent_upgrade_on_error(): b = sirepo.auth_db.db_filename() + ".bak" diff --git a/sirepo/feature_config.py b/sirepo/feature_config.py index 8d1bd4f9f6..e7f38230f7 100644 --- a/sirepo/feature_config.py +++ b/sirepo/feature_config.py @@ -216,6 +216,7 @@ def _test(msg): show_open_shadow=_test('Show "Open as a New Shadow Simulation" menu item'), show_rsopt_ml=_test('Show "Export ML Script" menu item'), ), + trial_expiration_days=(30, int, "number of days a sirepo trial is active"), trust_sh_env=( False, bool, diff --git a/sirepo/job_api.py b/sirepo/job_api.py index f103f3441e..e163176b60 100644 --- a/sirepo/job_api.py +++ b/sirepo/job_api.py @@ -54,12 +54,12 @@ async def api_admJobs(self): _request_content=self._parse_post_just_data(), ) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_analysisJob(self): # TODO(robnagler): computeJobHash has to be checked return await self._request_api() - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_beginSession(self): """Starts beginSession request asynchronously @@ -72,7 +72,7 @@ async def api_beginSession(self): ) @sirepo.quest.Spec( - "require_user", + "require_plan", sid="SimId", model="AnalysisModel", frame="DataFileIndex", @@ -87,7 +87,7 @@ async def api_downloadDataFile( ) @sirepo.quest.Spec( - "require_user", + "require_plan", sid="SimId", model="AnalysisModel", frame="DataFileIndex", @@ -148,7 +148,7 @@ def _content_too_large(req): f"frame={frame} not found sid={req.id} sim_type={req.type}", ) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_globalResources(self): assert ( sirepo.feature_config.cfg().enable_global_resources @@ -184,13 +184,13 @@ async def api_jobSupervisorPing(self): e = "unexpected exception" return PKDict(state="error", error=e) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_ownJobs(self): return await self._request_api( _request_content=self._parse_post_just_data(), ) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_runCancel(self): try: return await self._request_api() @@ -199,14 +199,14 @@ async def api_runCancel(self): # Always true from the client's perspective return self.reply_dict({"state": "canceled"}) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_runSimulation(self): r = self._request_content(PKDict()) if r.isParallel: r.isPremiumUser = self.auth.is_premium_user() return await self._request_api(_request_content=r) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_runStatus(self): # runStatus receives models when an animation status if first queried return await self._request_api(_request_content=self._request_content(PKDict())) @@ -229,7 +229,7 @@ async def api_sbatchLoginStatus(self): ) ) - @sirepo.quest.Spec("require_user", frame_id="SimFrameId") + @sirepo.quest.Spec("require_plan", frame_id="SimFrameId") async def api_simulationFrame(self, frame_id): return await template_common.sim_frame( frame_id, @@ -243,11 +243,11 @@ async def api_simulationFrame(self, frame_id): self, ) - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_statefulCompute(self): return await self._request_compute(op_key="stful") - @sirepo.quest.Spec("require_user") + @sirepo.quest.Spec("require_plan") async def api_statelessCompute(self): return await self._request_compute(op_key="stlss") diff --git a/sirepo/package_data/auth_role_moderation/approve_email.jinja b/sirepo/package_data/auth_role_moderation/approve_email.jinja index b993859404..216ceb21c9 100644 --- a/sirepo/package_data/auth_role_moderation/approve_email.jinja +++ b/sirepo/package_data/auth_role_moderation/approve_email.jinja @@ -1,3 +1,4 @@ -Your request for {{ app_name }} access has been approved. +Your request for Sirepo {{ role_friendly_name }} access has been approved. {{ link }} +{{ additional_text }} diff --git a/sirepo/package_data/auth_role_moderation/clarify_email.jinja b/sirepo/package_data/auth_role_moderation/clarify_email.jinja index 65d7f66ec2..6033ac4ae2 100644 --- a/sirepo/package_data/auth_role_moderation/clarify_email.jinja +++ b/sirepo/package_data/auth_role_moderation/clarify_email.jinja @@ -13,4 +13,3 @@ Thank you for your understanding. Sincerely, Sirepo Support - diff --git a/sirepo/package_data/auth_role_moderation/moderation_email.jinja b/sirepo/package_data/auth_role_moderation/moderation_email.jinja index 8c8a579cb4..39fa75fbea 100644 --- a/sirepo/package_data/auth_role_moderation/moderation_email.jinja +++ b/sirepo/package_data/auth_role_moderation/moderation_email.jinja @@ -1,5 +1,6 @@ Name: {{ display_name }} Simulation Type: {{ sim_type }} +Role: {{ role }} Reason: {{ reason }} Email: {{ email_addr }} DNS: {{ domain_name }} diff --git a/sirepo/package_data/static/html/http-plan-required.html b/sirepo/package_data/static/html/http-plan-required.html new file mode 100644 index 0000000000..c93086ff2a --- /dev/null +++ b/sirepo/package_data/static/html/http-plan-required.html @@ -0,0 +1,8 @@ +
+
+
+

Plan Required

+

An is required to use Sirepo.

+
+
+
diff --git a/sirepo/package_data/static/js/sirepo-components.js b/sirepo/package_data/static/js/sirepo-components.js index 34886dfbf2..450289d1a7 100644 --- a/sirepo/package_data/static/js/sirepo-components.js +++ b/sirepo/package_data/static/js/sirepo-components.js @@ -398,11 +398,11 @@ SIREPO.app.directive('canceledDueToTimeoutAlert', function(authState) { template: ` `, controller: function($scope, appState) { - $scope.authState = authState; + $scope.upgradeToLink = `please upgrade to a ${authState.upgradeToPlan} plan`; $scope.getTime = function() { return appState.formatTime($scope.simState.getCanceledAfterSecs()); @@ -1792,6 +1792,20 @@ SIREPO.app.directive('pendingLinkToSimulations', function() { }; }); + +SIREPO.app.directive('plansLink', function() { + return { + restrict: 'A', + scope: { + linkText: '@', + }, + template: '{{ linkText }}', + controller: function($scope) { + $scope.plansUrl = SIREPO.APP_SCHEMA.constants.plansUrl; + }, + }; +}); + SIREPO.app.directive('safePath', function() { // keep in sync with sirepo.srschem.py _NAME_ILLEGALS @@ -4765,7 +4779,7 @@ SIREPO.app.directive('sbatchLoginModal', function() {