Skip to content

Commit 705a7da

Browse files
authored
Allow oonirun authors to hide their email (#1249)
* Add share_email field to oonirun link model * Only share the email if allowed by author * Only share email if allowed by the user (get_latest_oonirun_link, get_oonirun_link_revision) * Fix is_mine mangling in list of links * Add share_email field to the update function * Fix update and create of oonirun links * Add tests for email sharing features * Update docs regarding author field * Add alembic migrations to add share_email field * Allow admin users to always see the author * rename share_email to publish_email * Return [private email] instead of null to avoid breaking UI in old clients This change was added as feedback from the mobile team to avoid showing a buggy UI in old clients. The clients will be updated to handle both this case and the null case, so we can get rid of this in the future after an updated is rolled out
1 parent da8b057 commit 705a7da

5 files changed

Lines changed: 318 additions & 15 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""add publish_email column to oonirun
2+
3+
Revision ID: 87f0bcd3dea6
4+
Revises: b860eb79750f
5+
Create Date: 2026-09-18 14:27:05.598053
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '87f0bcd3dea6'
16+
down_revision: Union[str, None] = 'b860eb79750f'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
op.add_column(
23+
"oonirun",
24+
sa.Column(
25+
"publish_email",
26+
sa.Boolean(),
27+
nullable=False,
28+
server_default=sa.true(),
29+
),
30+
)
31+
32+
33+
def downgrade() -> None:
34+
op.drop_column("oonirun", "publish_email")

‎ooniapi/services/oonirun/src/oonirun/models.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ class OONIRunLink(Base):
3434
author: Mapped[str] = mapped_column(nullable=True)
3535
icon: Mapped[str] = mapped_column(nullable=True)
3636
color: Mapped[str] = mapped_column(nullable=True)
37+
publish_email: Mapped[bool] = mapped_column(
38+
nullable=False,
39+
default=True,
40+
server_default=sa.true(),
41+
comment=(
42+
"Whether or not the email should be shown in the UI and "
43+
"shared with other users other than the owner"
44+
),
45+
)
3746

3847
nettests: Mapped[List["OONIRunLinkNettest"]] = relationship(
3948
back_populates="oonirun_link",

‎ooniapi/services/oonirun/src/oonirun/routers/v2.py‎

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..common.dependencies import get_settings, role_required
2424
from ..common.auth import (
2525
get_account_id_or_none,
26+
get_client_role,
2627
)
2728
from ..common.prio import generate_test_list
2829
from ..common.dependencies import ClickhouseDep, PostgresDep
@@ -38,6 +39,11 @@ def utcnow_seconds():
3839
return datetime.now(timezone.utc).replace(microsecond=0)
3940

4041

42+
# We use this string when the oonirun link is setup to not be shared.
43+
# We use it so that the UI in old clients won't break
44+
PRIVATE_EMAIL_PLACEHOLDER = "[private email]"
45+
46+
4147
NETWORK_TYPES = [
4248
"vpn",
4349
"wifi",
@@ -135,7 +141,7 @@ class OONIRunLinkBase(BaseModel):
135141
description: str = Field(
136142
title="full description of the ooni run link", min_length=2
137143
)
138-
author: str = Field(
144+
author: str | None = Field(
139145
title="public email address of the author name of the ooni run link",
140146
min_length=2,
141147
max_length=100,
@@ -180,6 +186,9 @@ def validate_intl(cls, v: Dict[str, str]):
180186
default_factory=lambda: utcnow_seconds() + timedelta(days=30 * 6),
181187
description="future time after which the ooni run link will be considered expired and no longer editable or usable (defaults to 6 months from now)",
182188
)
189+
publish_email: bool = Field(
190+
description="Whether to publish this email to other users",
191+
)
183192

184193

185194
class OONIRunLink(OONIRunLinkBase):
@@ -253,6 +262,7 @@ def create_oonirun_link(
253262
icon=create_request.icon,
254263
color=create_request.color,
255264
expiration_date=create_request.expiration_date,
265+
publish_email=create_request.publish_email,
256266
date_created=now,
257267
date_updated=now,
258268
)
@@ -291,6 +301,7 @@ def create_oonirun_link(
291301
icon=db_oonirun_link.icon,
292302
color=db_oonirun_link.color,
293303
expiration_date=db_oonirun_link.expiration_date,
304+
publish_email=db_oonirun_link.publish_email,
294305
date_created=db_oonirun_link.date_created,
295306
date_updated=db_oonirun_link.date_updated,
296307
nettests=nettest_list,
@@ -309,7 +320,9 @@ def edit_oonirun_link(
309320
db: PostgresDep,
310321
token=Depends(role_required(["admin", "user"])),
311322
):
312-
"""Edit an existing OONI Run link"""
323+
"""
324+
Edit an existing OONI Run link
325+
"""
313326
log.debug(f"edit oonirun {oonirun_link_id}")
314327
account_id = token["account_id"]
315328

@@ -392,6 +405,7 @@ def edit_oonirun_link(
392405
oonirun_link.color = edit_request.color
393406
oonirun_link.expiration_date = edit_request.expiration_date
394407
oonirun_link.date_updated = now
408+
oonirun_link.publish_email = edit_request.publish_email
395409
db.commit()
396410

397411
return OONIRunLink(
@@ -406,6 +420,7 @@ def edit_oonirun_link(
406420
icon=oonirun_link.icon,
407421
color=oonirun_link.color,
408422
expiration_date=oonirun_link.expiration_date,
423+
publish_email=oonirun_link.publish_email,
409424
oonirun_link_id=oonirun_link.oonirun_link_id,
410425
date_created=oonirun_link.date_created,
411426
date_updated=oonirun_link.date_updated,
@@ -506,6 +521,7 @@ def make_oonirun_link(
506521
oonirun_link_id: str,
507522
account_id: Optional[str],
508523
revision: Optional[int] = None,
524+
is_admin: bool = False,
509525
):
510526
q = db.query(models.OONIRunLink).filter(
511527
models.OONIRunLink.oonirun_link_id == oonirun_link_id
@@ -524,6 +540,7 @@ def make_oonirun_link(
524540
assert isinstance(revision, int)
525541

526542
nettests, date_created = get_nettests(res, revision)
543+
is_mine = account_id == res.creator_account_id
527544
return OONIRunLink(
528545
oonirun_link_id=res.oonirun_link_id,
529546
name=res.name,
@@ -538,9 +555,14 @@ def make_oonirun_link(
538555
nettests=nettests,
539556
date_created=date_created,
540557
date_updated=res.date_updated,
541-
is_mine=account_id == res.creator_account_id,
542-
author=res.author,
558+
is_mine=is_mine,
559+
author=(
560+
res.author
561+
if is_mine or is_admin or res.publish_email
562+
else PRIVATE_EMAIL_PLACEHOLDER
563+
),
543564
revision=str(revision),
565+
publish_email=res.publish_email
544566
)
545567

546568

@@ -678,12 +700,20 @@ def get_oonirun_link_revision(
678700
authorization: str = Header("authorization"),
679701
settings=Depends(get_settings),
680702
):
681-
"""Fetch an OONI Run link by specifying the revision number"""
703+
"""
704+
Fetch an OONI Run link by specifying the revision number
705+
706+
Note that the author field is replaced with the placeholder "[private email]" for other users
707+
if the author has set publish_email to False.
708+
"""
682709
# Return the latest version of the translations
683710
log.debug("fetching oonirun")
684711
account_id = get_account_id_or_none(
685712
authorization, jwt_encryption_key=settings.jwt_encryption_key
686713
)
714+
is_admin = (
715+
get_client_role(authorization, settings.jwt_encryption_key) == "admin"
716+
)
687717

688718
try:
689719
revision = int(revision_number)
@@ -693,7 +723,11 @@ def get_oonirun_link_revision(
693723
revision = None
694724

695725
oonirun_link = make_oonirun_link(
696-
db=db, oonirun_link_id=oonirun_link_id, account_id=account_id, revision=revision
726+
db=db,
727+
oonirun_link_id=oonirun_link_id,
728+
account_id=account_id,
729+
revision=revision,
730+
is_admin=is_admin,
697731
)
698732
return oonirun_link
699733

@@ -706,16 +740,27 @@ def get_latest_oonirun_link(
706740
db: PostgresDep,
707741
authorization: str = Header("authorization"),
708742
settings=Depends(get_settings),
709-
):
710-
"""Fetch OONIRun descriptor by creation time or the newest one"""
743+
) -> OONIRunLink:
744+
"""
745+
Fetch OONIRun descriptor by creation time or the newest one
746+
747+
Note that the author field is replaced with the placeholder "[private email]" for other users
748+
if the author has set publish_email to False.
749+
"""
711750
# Return the latest version of the translations
712751
log.debug("fetching oonirun")
713752
account_id = get_account_id_or_none(
714753
authorization, jwt_encryption_key=settings.jwt_encryption_key
715754
)
755+
is_admin = (
756+
get_client_role(authorization, settings.jwt_encryption_key) == "admin"
757+
)
716758

717759
oonirun_link = make_oonirun_link(
718-
db=db, oonirun_link_id=oonirun_link_id, account_id=account_id
760+
db=db,
761+
oonirun_link_id=oonirun_link_id,
762+
account_id=account_id,
763+
is_admin=is_admin,
719764
)
720765
return oonirun_link
721766

@@ -738,9 +783,17 @@ def list_oonirun_links(
738783
authorization: str = Header("authorization"),
739784
settings=Depends(get_settings),
740785
) -> OONIRunLinkList:
741-
"""List OONIRun descriptors"""
786+
"""
787+
List OONIRun descriptors
788+
789+
Note that the author field is replaced with the placeholder "[private email]" for other users
790+
if the author has set publish_email to False.
791+
"""
742792
log.debug("list oonirun")
743793
account_id = get_account_id_or_none(authorization, settings.jwt_encryption_key)
794+
is_admin = (
795+
get_client_role(authorization, settings.jwt_encryption_key) == "admin"
796+
)
744797

745798
q = db.query(models.OONIRunLink)
746799
if not is_expired:
@@ -766,14 +819,19 @@ def list_oonirun_links(
766819
short_description_intl=row.short_description_intl,
767820
description=row.description,
768821
description_intl=row.description_intl,
769-
author=row.author,
822+
author=(
823+
row.author
824+
if account_id == row.creator_account_id or is_admin or row.publish_email
825+
else PRIVATE_EMAIL_PLACEHOLDER
826+
),
770827
nettests=nettests,
771828
icon=row.icon,
772829
expiration_date=row.expiration_date,
773830
revision=str(revision),
774831
date_created=row.date_created,
775832
date_updated=row.date_updated,
776833
is_mine=account_id == row.creator_account_id,
834+
publish_email=row.publish_email
777835
)
778836
links.append(oonirun_link)
779837
log.debug(f"Returning {len(links)} ooni run links")

‎ooniapi/services/oonirun/tests/conftest.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,17 @@ def create_jwt(payload: dict) -> str:
7373
return jwt.encode(payload, "super_secure", algorithm="HS256")
7474

7575

76-
def create_session_token(account_id: str, role: str) -> str:
76+
def create_session_token(
77+
account_id: str, role: str, email_address: str = "oonitarian@example.com"
78+
) -> str:
7779
now = int(time.time())
7880
payload = {
7981
"nbf": now,
8082
"iat": now,
8183
"exp": now + 10 * 86400,
8284
"aud": "user_auth",
8385
"account_id": account_id,
84-
"email_address": "oonitarian@example.com",
86+
"email_address": email_address,
8587
"login_time": None,
8688
"role": role,
8789
}
@@ -96,10 +98,22 @@ def client_with_user_role(client):
9698
yield client
9799

98100

101+
@pytest.fixture
102+
def client_with_other_user_role(client):
103+
client = TestClient(app)
104+
jwt_token = create_session_token(
105+
"1" * 16, "user", email_address="someoneelse@example.com"
106+
)
107+
client.headers = {"Authorization": f"Bearer {jwt_token}"}
108+
yield client
109+
110+
99111
@pytest.fixture
100112
def client_with_admin_role(client):
101113
client = TestClient(app)
102-
jwt_token = create_session_token("0" * 16, "admin")
114+
jwt_token = create_session_token(
115+
"9" * 16, "admin", email_address="admin@example.com"
116+
)
103117
client.headers = {"Authorization": f"Bearer {jwt_token}"}
104118
yield client
105119

0 commit comments

Comments
 (0)