Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #7424: make all of Sirepo moderated #7426

Merged
merged 10 commits into from
Jan 28, 2025
Merged
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion etc/run.sh
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion sirepo/api_auth.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 3 additions & 1 deletion sirepo/api_perm.py
Original file line number Diff line number Diff line change
@@ -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 premium plan is required
REQUIRE_PREMIUM = aenum.auto()


31 changes: 20 additions & 11 deletions sirepo/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@
_GUEST_USER_DISPLAY_NAME = "Guest User"

_PAYMENT_PLAN_BASIC = "basic"
_PAYMENT_PLAN_PREMIUM = sirepo.auth_role.ROLE_PAYMENT_PLAN_PREMIUM
_PAYMENT_PLAN_PREMIUM = sirepo.auth_role.ROLE_PLAN_PREMIUM
_ALL_PAYMENT_PLANS = (
_PAYMENT_PLAN_BASIC,
_PAYMENT_PLAN_PREMIUM,
@@ -172,19 +172,17 @@ 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)
raise sirepo.util.Forbidden(f"uid={u} does not have access to sim_type={t}")

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,8 +272,8 @@ 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(
role=sirepo.auth_role.ROLE_PAYMENT_PLAN_PREMIUM,
return self.qcall.auth_db.model("UserRole").has_active_role(
role=sirepo.auth_role.ROLE_PLAN_PREMIUM,
)

def logged_in_user(self, check_path=True):
@@ -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.PLAN_ROLES:
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_PLAN_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
@@ -763,7 +772,7 @@ def _method_user_model(self, module, uid):

def _plan(self, data):
r = data.roles
if sirepo.auth_role.ROLE_PAYMENT_PLAN_PREMIUM in r:
if sirepo.auth_role.ROLE_PLAN_PREMIUM in r:
data.paymentPlan = _PAYMENT_PLAN_PREMIUM
data.upgradeToPlan = None
else:
7 changes: 5 additions & 2 deletions sirepo/auth_db/__init__.py
Original file line number Diff line number Diff line change
@@ -188,8 +188,11 @@ def execute(self, statement):
statement.execution_options(synchronize_session="fetch")
)

def execute_sql(self, text):
return self.execute(sqlalchemy.text(text + ";"))
def execute_sql(self, text, params=None):
q = sqlalchemy.text(text + ";")
if params:
q = q.bindparams(**params)
return self.execute(q)

def metadata(self):
return UserDbBase.metadata
40 changes: 24 additions & 16 deletions sirepo/auth_db/user.py
Original file line number Diff line number Diff line change
@@ -36,17 +36,17 @@ 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
pass
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,22 +71,22 @@ 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)
return self.uids_with_roles(sirepo.auth_role.PLAN_ROLES_PAID)

def uids_with_roles(self, roles):
a = sirepo.auth_role.get_all()
@@ -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"
9 changes: 6 additions & 3 deletions sirepo/auth_role.py
Original file line number Diff line number Diff line change
@@ -10,9 +10,11 @@
import sirepo.feature_config

ROLE_ADM = "adm"
ROLE_PLAN_PREMIUM = "premium"
ROLE_PLAN_TRIAL = "trial"
ROLE_USER = "user"
ROLE_PAYMENT_PLAN_PREMIUM = "premium"
PAID_USER_ROLES = (ROLE_PAYMENT_PLAN_PREMIUM,)
PLAN_ROLES_PAID = frozenset((ROLE_PLAN_PREMIUM,))
PLAN_ROLES = PLAN_ROLES_PAID.union((ROLE_PLAN_TRIAL,))
_SIM_TYPE_ROLE_PREFIX = "sim_type_"


@@ -58,7 +60,8 @@ def get_all():
for_sim_type(t) for t in sirepo.feature_config.auth_controlled_sim_types()
] + [
ROLE_ADM,
ROLE_PAYMENT_PLAN_PREMIUM,
ROLE_PLAN_PREMIUM,
ROLE_PLAN_TRIAL,
ROLE_USER,
]

76 changes: 56 additions & 20 deletions sirepo/auth_role_moderation.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
# -*- coding: utf-8 -*-
"""Moderate user roles

:copyright: Copyright (c) 2022 RadiaSoft LLC. All Rights Reserved.
: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.const
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",
@@ -42,14 +45,37 @@ class API(sirepo.quest.API):
"require_adm", uid="Str", role="Str", status="AuthModerationStatus"
)
async def api_admModerate(self):
def _role_info(role, status):
res = PKDict(
role=role,
status=status,
moderator_uid=self.auth.logged_in_user(),
)
if role == sirepo.auth_role.ROLE_PLAN_TRIAL:
return res.pkupdate(
role_display_name="Trial",
product_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.",
)
a = sirepo.simulation_db.SCHEMA_COMMON.appInfo[
sirepo.auth_role.sim_type(role)
].longName
return res.pkupdate(role_display_name=a, product_name=a)

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_display_name}: {_STATUS_TO_SUBJECT[info.status]}",
body=pkjinja.render_resource(
f"auth_role_moderation/{info.status}_email",
PKDict(
app_name=info.app_name,
additional_text=info.get("additional_text"),
product_name=info.product_name,
role_display_name=info.role_display_name,
display_name=info.display_name,
link=self.absolute_uri(
self.uri_for_app_root(
@@ -62,7 +88,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,
@@ -87,14 +115,7 @@ def _set_moderation_status(info):
req.req_data.uid,
req.req_data.role,
)
p = PKDict(
app_name=sirepo.simulation_db.SCHEMA_COMMON.appInfo[
sirepo.auth_role.sim_type(i.role)
].longName,
role=i.role,
status=req.req_data.status,
moderator_uid=self.auth.logged_in_user(),
)
p = _role_info(i.role, req.req_data.status)
pkdlog("status={} uid={} role={}", p.status, i.uid, i.role)
# Force METHOD_EMAIL. We are sending them an email so we will
# need an email for them. We only have emails for METHOD_EMAIL
@@ -153,9 +174,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_PLAN_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_PLAN_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 +213,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 +230,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_PLAN_TRIAL:
raise sirepo.util.PlanExpired(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 +248,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@{sirepo.const.LOCALHOST_FQDN}",
str,
"The email address to send moderation emails to",
),
)
x = frozenset(_STATUS_TO_SUBJECT.keys())
2 changes: 2 additions & 0 deletions sirepo/const.py
Original file line number Diff line number Diff line change
@@ -16,6 +16,8 @@
#: where template resources and template non-sim user files live
LIB_DIR = "lib"

LOCALHOST_FQDN = "localhost.localdomain"

# matches requirements for uid and isn't actually put in the db
MOCK_UID = "someuser"

Loading
Loading