Skip to content

Commit 3f02ab6

Browse files
committed
feat: notify users about expiring API access tokens
1 parent 1836332 commit 3f02ab6

13 files changed

+251
-69
lines changed

database/090-access-token.sql

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,46 @@ $$;
9696

9797

9898
CREATE FUNCTION cleanup_expired_token() RETURNS VOID AS $$
99+
DECLARE
100+
deleted_row RECORD;
101+
rows_deleted INTEGER := 0;
102+
soon_expiring RECORD;
103+
soon_expiring_count INTEGER := 0;
99104
BEGIN
100-
DELETE FROM user_access_token WHERE expires_at <= CURRENT_DATE;
105+
FOR deleted_row IN
106+
DELETE FROM user_access_token
107+
WHERE expires_at <= CURRENT_DATE
108+
RETURNING *
109+
LOOP
110+
-- Send notification for each deleted row
111+
PERFORM pg_notify(
112+
'access_token_deleted_now',
113+
json_build_object(
114+
'id', deleted_row.id,
115+
'account', deleted_row.account,
116+
'display_name', deleted_row.display_name
117+
)::text
118+
);
119+
rows_deleted := rows_deleted + 1;
120+
END LOOP;
121+
RAISE NOTICE 'Deleted % access tokens and sent notifications', rows_deleted;
122+
123+
FOR soon_expiring IN
124+
SELECT * FROM user_access_token
125+
WHERE expires_at::date = CURRENT_DATE + 7
126+
LOOP
127+
-- Send notification for each token expiring in 7 days
128+
PERFORM pg_notify(
129+
'access_token_expiring_7_days',
130+
json_build_object(
131+
'id', soon_expiring.id,
132+
'account', soon_expiring.account,
133+
'display_name', soon_expiring.display_name
134+
)::text
135+
);
136+
soon_expiring_count := soon_expiring_count + 1;
137+
END LOOP;
138+
RAISE NOTICE '% access tokens expiring in 7 days, sent notifications', soon_expiring_count;
139+
101140
END;
102141
$$ LANGUAGE plpgsql;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
# SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
# SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
8+
from app.templates.common import render_template
9+
from .base import ChannelHandler
10+
from app.services.postgrest_helpers import get_mail_for_account
11+
from app import utils
12+
13+
class AccessTokenDeletedHandler(ChannelHandler):
14+
def __init__(self):
15+
super().__init__("access_token_deleted_now")
16+
17+
def preprocess(self, payload):
18+
recipient = get_mail_for_account(payload["account"])
19+
return dict(
20+
subject="RSD: Your API access token expired",
21+
recipients=[recipient],
22+
html_content=render_template("access_token_expired_now.html", {"DISPLAY_NAME": payload["display_name"]}),
23+
plain_content=None
24+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
# SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
8+
from app.templates.common import render_template
9+
from .base import ChannelHandler
10+
from app.services.postgrest_helpers import get_mail_for_account
11+
from app import utils
12+
13+
class AccessTokenExpiringHandler(ChannelHandler):
14+
def __init__(self):
15+
super().__init__("access_token_expiring_7_days")
16+
17+
def preprocess(self, payload):
18+
recipient = get_mail_for_account(payload["account"])
19+
return dict(
20+
subject="RSD: Your API access token will expire in 7 days",
21+
recipients=[recipient],
22+
html_content=render_template("access_token_expiring_soon.html", {"DISPLAY_NAME": payload["display_name"]}),
23+
plain_content=None
24+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
# SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
from app import utils
8+
9+
class ChannelHandler:
10+
def __init__(self, name):
11+
self.name = name
12+
13+
def preprocess(self, payload):
14+
"""Must override and return mail body dict"""
15+
raise NotImplementedError("Process method must be implemented.")
16+
17+
def process(self, mail_body):
18+
return utils.publish_to_queue(os.environ.get("MAIL_QUEUE", "mailq"), mail_body)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
# SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
8+
from app.templates.common import render_template
9+
from .base import ChannelHandler
10+
from app.services.postgrest_helpers import get_software_name, get_maintainer_emails_for_community, get_community_info
11+
from app import utils
12+
13+
class SoftwareCommunityJoinRequestHandler(ChannelHandler):
14+
def __init__(self):
15+
super().__init__("software_for_community_join_request")
16+
17+
def preprocess(self, payload):
18+
software_name, software_slug = get_software_name(payload["software"])
19+
recipients = get_maintainer_emails_for_community(payload["community"])
20+
community_name, community_slug = get_community_info(payload["community"])
21+
software_page_url = utils.create_software_page_url(software_slug)
22+
community_settings_url = utils.create_community_requests_url(community_slug)
23+
return dict(
24+
subject=f"RSD: Community join request for {community_name}",
25+
recipients=recipients,
26+
html_content=render_template("community_join_request.html", {"SOFTWARE_NAME": software_name, "COMMUNITY_NAME": community_name, "SOFTWARE_PAGE_URL": software_page_url, "COMMUNITY_REQUESTS_URL": community_settings_url, "RSD_URL": os.getenv("HOST_URL")}),
27+
plain_content=None
28+
)

publisher/app/listeners/postgres_notifications_listener.py

Lines changed: 25 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,22 @@
66
import psycopg
77
import select
88
import json
9-
import time
109
import os
11-
import requests
1210
import app.utils as utils
13-
from app.templates.common import render_template
1411
from app.auth import JwtProvider
12+
from channels.software_for_community_join_request import SoftwareCommunityJoinRequestHandler
13+
from channels.access_token_deleted import AccessTokenDeletedHandler
14+
from channels.access_token_expiring import AccessTokenExpiringHandler
1515

1616
BASE_URL=os.getenv("POSTGREST_URL")
1717
JWT_PROVIDER = JwtProvider()
1818

19+
CHANNEL_HANDLERS = [
20+
SoftwareCommunityJoinRequestHandler(),
21+
AccessTokenDeletedHandler(),
22+
AccessTokenExpiringHandler()
23+
]
24+
1925
def connect_to_postgres():
2026
for i in range(5):
2127
try:
@@ -30,85 +36,37 @@ def connect_to_postgres():
3036
except psycopg.OperationalError as e:
3137
print(f"Connecting attempt {i+1} Publisher to Postgres database failed: {e}")
3238

33-
def listen_to_channel(cursor, channel_name):
34-
cursor.execute(f"LISTEN {channel_name};")
39+
def listen_to_channels(conn, channel_handlers):
40+
cursor = conn.cursor()
41+
for handler in channel_handlers:
42+
cursor.execute(f"LISTEN {handler.name};")
3543

36-
def process_notifications(connection):
44+
print("Listening to channels...")
3745
while True:
3846
try:
39-
if select.select([connection], [], [], 5) == ([], [], []):
47+
if select.select([conn], [], [], 5) == ([], [], []):
4048
continue
41-
42-
for notify in connection.notifies():
43-
payload = json.loads(notify.payload)
44-
if notify.channel == "software_for_community_join_request":
45-
software_name, software_slug = get_software_name(payload["software"])
46-
recipients = get_maintainer_emails_for_community(payload["community"])
47-
community_name, community_slug = get_community_info(payload["community"])
48-
if community_name:
49-
send_community_join_request_mail(recipients, software_name, community_name, utils.create_software_page_url(software_slug), utils.create_community_requests_url(community_slug))
50-
49+
for notify in conn.notifies():
50+
process_notifications(channel_handlers, notify.channel, json.loads(notify.payload))
5151
except (Exception, psycopg.DatabaseError) as error:
5252
utils.log_to_backend(
5353
service_name="Postgres Notification Listener",
5454
table_name="",
55-
message=f"Exception while listening to Postgres (software_for_community_join_request): {error}",
55+
message=f"Exception while listening to Postgres: {error}",
5656
)
5757
print(error)
5858
break
5959

6060

61-
def get_maintainer_emails_for_community(community_id):
62-
response = requests.post(
63-
f"{BASE_URL}/rpc/maintainers_of_community",
64-
headers={
65-
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
66-
"Content-Type": "application/json",
67-
},
68-
json={
69-
'community_id': community_id
70-
}
71-
)
72-
return [maintainer['email'][0] for maintainer in response.json()]
73-
74-
def get_community_info(community_id):
75-
response = requests.get(
76-
f"{BASE_URL}/community?id=eq.{community_id}&select=name, slug",
77-
headers={
78-
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
79-
"Content-Type": "application/json",
80-
}
81-
)
82-
return response.json()[0]["name"], response.json()[0]["slug"]
83-
84-
def get_software_name(software_id):
85-
response = requests.get(
86-
f"{BASE_URL}/software?id=eq.{software_id}&select=brand_name, slug",
87-
headers={
88-
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
89-
"Content-Type": "application/json",
90-
}
91-
)
92-
return response.json()[0]["brand_name"], response.json()[0]["slug"]
61+
def process_notifications(handlers, channel_name, payload):
62+
for handler in handlers:
63+
if handler.name == channel_name:
64+
preprocessed = handler.preprocess(payload)
65+
handler.process(preprocessed)
66+
break
9367

94-
def send_community_join_request_mail(recipients, software_name, community_name, software_page_url, community_settings_url):
95-
subject = f"RSD: Community join request for {community_name}"
96-
html_content = render_template("community_join_request.html", {"SOFTWARE_NAME": software_name, "COMMUNITY_NAME": community_name, "SOFTWARE_PAGE_URL": software_page_url, "COMMUNITY_REQUESTS_URL": community_settings_url, "RSD_URL": os.getenv("HOST_URL")})
97-
body = dict(
98-
subject=subject,
99-
recipients=recipients,
100-
html_content=html_content,
101-
plain_content=None
102-
)
103-
utils.publish_to_queue(os.environ.get("MAIL_QUEUE", "mailq"), body)
10468

10569
if __name__ == "__main__":
10670
connection = connect_to_postgres()
10771
connection.autocommit = True
108-
109-
cursor = connection.cursor()
110-
111-
# listen for community join requests
112-
listen_to_channel(cursor, "software_for_community_join_request")
113-
process_notifications(connection)
114-
72+
listen_to_channels(connection, CHANNEL_HANDLERS)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
# SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
import requests
8+
from app.auth import JwtProvider
9+
10+
BASE_URL=os.getenv("POSTGREST_URL")
11+
JWT_PROVIDER = JwtProvider()
12+
13+
def get_maintainer_emails_for_community(community_id):
14+
response = requests.post(
15+
f"{BASE_URL}/rpc/maintainers_of_community",
16+
headers={
17+
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
18+
"Content-Type": "application/json",
19+
},
20+
json={
21+
'community_id': community_id
22+
}
23+
)
24+
return [maintainer['email'][0] for maintainer in response.json()]
25+
26+
def get_community_info(community_id):
27+
response = requests.get(
28+
f"{BASE_URL}/community?id=eq.{community_id}&select=name,slug",
29+
headers={
30+
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
31+
"Content-Type": "application/json",
32+
}
33+
)
34+
return response.json()[0]["name"], response.json()[0]["slug"]
35+
36+
def get_software_name(software_id):
37+
response = requests.get(
38+
f"{BASE_URL}/software?id=eq.{software_id}&select=brand_name, slug",
39+
headers={
40+
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
41+
"Content-Type": "application/json",
42+
}
43+
)
44+
return response.json()[0]["brand_name"], response.json()[0]["slug"]
45+
46+
def get_mail_for_account(account_id):
47+
response = requests.get(
48+
f"{BASE_URL}/user_profile?account=eq.{account_id}&select=email_address",
49+
headers={
50+
"Authorization": f"Bearer {JWT_PROVIDER.get_admin_jwt()}",
51+
"Content-Type": "application/json",
52+
}
53+
)
54+
return response.json()[0]["email_address"]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html>
3+
4+
<head>
5+
<title>API Access Token expired</title>
6+
</head>
7+
8+
<body>
9+
Your API access token "{{DISPLAY_NAME}}" has expired and was deleted. If you are still using this token, you need to generate a new one.
10+
</body>
11+
12+
</html>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SPDX-FileCopyrightText: 2025 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
2+
SPDX-FileCopyrightText: 2025 Paula Stock (GFZ) <[email protected]>
3+
4+
SPDX-License-Identifier: Apache-2.0

0 commit comments

Comments
 (0)