Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions deploy/providers/AWS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1185,8 +1185,10 @@ deploy/providers/AWS/
│ └── ses/
│ ├── flip-access-request.html # Access request notification
│ ├── flip-access-request.txt # Plain-text fallback
│ ├── flip-xnat-credentials.html # XNAT credential notification
│ └── flip-xnat-credentials.txt # Plain-text fallback
│ ├── flip-xnat-invite.html # XNAT set-your-own-password invite link (FLIP-PT-079)
│ ├── flip-xnat-invite.txt # Plain-text fallback
│ ├── flip-xnat-added-to-project.html # Existing-user added-to-project notification
│ └── flip-xnat-added-to-project.txt # Plain-text fallback
├── services.tf # Cognito config - loads cognito/ templates via file()
├── main.tf # SES config - loads ses/ templates via file()
└── tests/
Expand Down
5 changes: 0 additions & 5 deletions deploy/providers/AWS/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -812,11 +812,6 @@ moved {
to = module.ses.aws_ses_template.flip_access_request
}

moved {
from = aws_ses_template.flip_xnat_credentials
to = module.ses.aws_ses_template.flip_xnat_credentials
}

moved {
from = aws_ses_template.flip_xnat_added_to_project
to = module.ses.aws_ses_template.flip_xnat_added_to_project
Expand Down
12 changes: 7 additions & 5 deletions deploy/providers/AWS/modules/ses/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ resource "aws_ses_template" "flip_access_request" {
text = file("${var.templates_dir}/flip-access-request.txt")
}

resource "aws_ses_template" "flip_xnat_credentials" {
name = var.template_name_prefix == "" ? "flip-xnat-credentials" : "${var.template_name_prefix}-flip-xnat-credentials"
subject = "Your XNAT credentials for {{trust_name}}"
html = file("${var.templates_dir}/flip-xnat-credentials.html")
text = file("${var.templates_dir}/flip-xnat-credentials.txt")
# Invite email: carries a host-less "set your own password" link (an XNAT alias-token path),
# never a password (FLIP-PT-079). Renamed from the retired flip-xnat-credentials template.
resource "aws_ses_template" "flip_xnat_invite" {
name = var.template_name_prefix == "" ? "flip-xnat-invite" : "${var.template_name_prefix}-flip-xnat-invite"
subject = "Set your XNAT password for {{trust_name}}"
html = file("${var.templates_dir}/flip-xnat-invite.html")
text = file("${var.templates_dir}/flip-xnat-invite.txt")
}

resource "aws_ses_template" "flip_xnat_added_to_project" {
Expand Down
2 changes: 1 addition & 1 deletion deploy/providers/AWS/modules/ses/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ output "template_names" {
description = "Map of logical name -> rendered SES template name, useful for callers that need to invoke the templates."
value = {
access_request = aws_ses_template.flip_access_request.name
xnat_credentials = aws_ses_template.flip_xnat_credentials.name
xnat_invite = aws_ses_template.flip_xnat_invite.name
xnat_added_to_project = aws_ses_template.flip_xnat_added_to_project.name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,29 @@ <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700; letter-
<td style="padding: 40px 30px;">
<p style="margin: 0 0 24px 0; color: #301A37; font-size: 16px; line-height: 1.5;">Hello,</p>

<p style="margin: 0 0 24px 0; color: #434757; font-size: 16px; line-height: 1.6;">An XNAT account has been created at trust <strong>{{trust_name}}</strong>. You have been granted access to project: <strong>{{project_name}}</strong> (ID: <strong>{{project_id}}</strong>).</p>
<p style="margin: 0 0 24px 0; color: #434757; font-size: 16px; line-height: 1.6;">An XNAT account has been created for you at trust <strong>{{trust_name}}</strong>. You have been granted access to project: <strong>{{project_name}}</strong> (ID: <strong>{{project_id}}</strong>).</p>

<!-- Credentials section -->
<!-- Set-password section -->
<div style="background-color: #F7F3F9; border-left: 4px solid #9452A8; padding: 20px; margin: 24px 0; border-radius: 4px;">
<p style="margin: 0 0 16px 0; color: #301A37; font-size: 14px; font-weight: 600;">Your XNAT credentials:</p>
<p style="margin: 0 0 16px 0; color: #301A37; font-size: 14px; font-weight: 600;">Set your password to activate your account:</p>
<table cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="padding: 8px 0; color: #61366e; font-weight: 600; width: 120px; font-size: 14px;">Username:</td>
<td style="padding: 8px 0; color: #301A37; font-family: 'Monaco', 'Courier New', monospace; font-size: 14px; word-break: break-word;">{{username}}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #61366e; font-weight: 600; font-size: 14px;">Password:</td>
<td style="padding: 8px 0; color: #301A37; font-family: 'Monaco', 'Courier New', monospace; font-size: 16px; letter-spacing: 2px; font-weight: 600;">{{password}}</td>
<td style="padding: 8px 0; color: #61366e; font-weight: 600; font-size: 14px; vertical-align: top;">Set-password link:</td>
<td style="padding: 8px 0; color: #301A37; font-family: 'Monaco', 'Courier New', monospace; font-size: 13px; word-break: break-all;">{{setup_path}}</td>
</tr>
</table>
</div>

<p style="margin: 24px 0 0 0; color: #434757; font-size: 16px; line-height: 1.6;">Please change your password when you first log in.</p>
<p style="margin: 24px 0 0 0; color: #434757; font-size: 16px; line-height: 1.6;">Open your trust's XNAT in a web browser, then append the set-password link path above to its address and go to it. You'll be asked to choose your own password — no password is ever sent by email.</p>

<!-- Enclave network note -->
<div style="background-color: #FFF7E6; border-left: 4px solid #E0A106; padding: 16px 20px; margin: 24px 0; border-radius: 4px;">
<p style="margin: 0; color: #6B4E00; font-size: 14px; line-height: 1.6;"><strong>Important:</strong> XNAT is only reachable from inside {{trust_name}}'s secure network. Open this link from a machine connected to that network (for example, using the connection you normally use to reach XNAT). It cannot be opened over the public internet. The link can be used once and expires.</p>
</div>

<!-- Confidentiality notice -->
<p style="margin: 24px 0 0 0; color: #434757; font-size: 13px; line-height: 1.6;">This is an automated message. Please do not reply to this email. This message may contain confidential information. If you are not the intended recipient, please do not disclose, copy, or distribute it.</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ FLIP - Federated Learning & Interoperability Platform

Hello,

An XNAT account has been created at trust {{trust_name}}. You have been granted access to project: {{project_name}} (ID: {{project_id}}).
An XNAT account has been created for you at trust {{trust_name}}. You have been granted access to project: {{project_name}} (ID: {{project_id}}).

Your XNAT credentials:
Set your password to activate your account:
Username: {{username}}
Password: {{password}}
Set-password link: {{setup_path}}

Please change your password when you first log in.
Open your trust's XNAT in a web browser, then append the set-password link path above to its address and go to it. You'll be asked to choose your own password - no password is ever sent by email.

IMPORTANT: XNAT is only reachable from inside {{trust_name}}'s secure network. Open this link from a machine connected to that network (for example, using the connection you normally use to reach XNAT). It cannot be opened over the public internet. The link can be used once and expires.

This is an automated message. Please do not reply to this email. This message may contain confidential information. If you are not the intended recipient, please do not disclose, copy, or distribute it.

Expand Down
12 changes: 6 additions & 6 deletions deploy/providers/AWS/tests/test_email_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class TestSesData:
project_name: str = "Brain Tumour Segmentation"
project_id: str = "BTS-001"
username: str = "jdoe"
password: str = "X7k!mP2$vR9n"
setup_path: str = "/app/template/XDATScreen_UpdateUser.vm?a=alias-123&s=secret-abc"


class EmailTemplateTester:
Expand All @@ -83,7 +83,7 @@ def __init__(self, test_user: TestUser | None = None, test_ses: TestSesData | No
# Load SES templates
ses_dir = aws_dir / "templates" / "ses"
self.ACCESS_REQUEST_TEMPLATE_HTML = (ses_dir / "flip-access-request.html").read_text()
self.XNAT_CREDENTIALS_TEMPLATE_HTML = (ses_dir / "flip-xnat-credentials.html").read_text()
self.XNAT_INVITE_TEMPLATE_HTML = (ses_dir / "flip-xnat-invite.html").read_text()

def substitute_placeholders(self, template: str) -> tuple[str, dict[str, str]]:
"""
Expand Down Expand Up @@ -112,7 +112,7 @@ def substitute_placeholders(self, template: str) -> tuple[str, dict[str, str]]:
"{{project_name}}": self.test_ses.project_name,
"{{project_id}}": self.test_ses.project_id,
"{{username}}": self.test_ses.username,
"{{password}}": self.test_ses.password,
"{{setup_path}}": self.test_ses.setup_path,
}

rendered = template
Expand Down Expand Up @@ -192,7 +192,7 @@ def render_and_save(self, output_dir: Path) -> None:
("password_reset_code", self.PASSWORD_RESET_CODE_TEMPLATE_HTML),
("password_reset_link", self.PASSWORD_RESET_LINK_TEMPLATE_HTML),
("access_request", self.ACCESS_REQUEST_TEMPLATE_HTML),
("xnat_credentials", self.XNAT_CREDENTIALS_TEMPLATE_HTML),
("xnat_invite", self.XNAT_INVITE_TEMPLATE_HTML),
]

for name, template in templates:
Expand Down Expand Up @@ -261,7 +261,7 @@ def test_all(self) -> list[dict]:
("Password Reset (Code)", self.PASSWORD_RESET_CODE_TEMPLATE_HTML),
("Password Reset (Link)", self.PASSWORD_RESET_LINK_TEMPLATE_HTML),
("Access Request", self.ACCESS_REQUEST_TEMPLATE_HTML),
("XNAT Credentials", self.XNAT_CREDENTIALS_TEMPLATE_HTML),
("XNAT Invite", self.XNAT_INVITE_TEMPLATE_HTML),
]

results = []
Expand Down Expand Up @@ -353,7 +353,7 @@ def main():
if args.serve:
print(f"\n📧 Starting local HTTP server at http://localhost:{args.port}")
print("Open the following URLs in your browser:")
for name in ["invite", "password_reset_code", "password_reset_link", "access_request", "xnat_credentials"]:
for name in ["invite", "password_reset_code", "password_reset_link", "access_request", "xnat_invite"]:
print(f" • http://localhost:{args.port}/flip_email_{name}.html")
print("\nPress Ctrl+C to stop the server.\n")

Expand Down
12 changes: 5 additions & 7 deletions docs/source/components/component-xnat.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,14 @@ XNAT UI

.. _receiving-xnat-credentials:

Receiving XNAT Account Credentials
==================================
Receiving XNAT Account Access
=============================

On approval of a FLIP project, any associated users will be granted access to the respective XNAT project at each trust. New XNAT user accounts will be generated as necessary. The email address associated with the FLIP user account will be sent details of their XNAT account credentials pertaining to each participating trust.
On approval of a FLIP project, any associated users are granted access to the respective XNAT project at each participating trust, and new XNAT user accounts are created as necessary.

.. figure:: ../assets/xnat/credentials_email.png
:width: 500
:align: center
If a new XNAT account is created for you, the email address associated with your FLIP account receives a one-time link to set your own password for that trust's XNAT — no password is ever sent by email. Because XNAT is only reachable from inside the trust's secure network (see `Access`_ below), open the link from a machine on that network; it can be used once and expires.

Email sent with XNAT account credentials.
If you already have an XNAT account at a trust and are later added to a further project there, you instead receive a notification that you have been added to the project — log in with your existing credentials.

Access
======
Expand Down
12 changes: 9 additions & 3 deletions flip-api/src/flip_api/domain/interfaces/trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,14 @@ class ICreateImagingProject(BaseModel):


class ICreatedImagingUser(BaseModel):
"""Represents a user created on XNAT. Used to be called IImageUser in the old repo."""
"""Represents a user created on XNAT. Used to be called IImageUser in the old repo.

Carries the AES-encrypted, host-less setup path (an XNAT alias-token link) the new user follows
to set their own password — never a password. See FLIP-PT-079.
"""

username: str
encrypted_password: str
encrypted_setup_path: str
email: EmailStr


Expand All @@ -139,11 +143,13 @@ class ICreatedImagingProject(BaseModel):


class ISesTemplateData(BaseModel):
"""Template data for the XNAT invite email: a host-less setup link, never a password (FLIP-PT-079)."""

trust_name: str
project_name: str
project_id: UUID
username: str
password: str
setup_path: str


class ISesProjectAccessTemplateData(BaseModel):
Expand Down
16 changes: 9 additions & 7 deletions flip-api/src/flip_api/private_services/imaging_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
ISesTemplateData,
)
from flip_api.private_services.project_images_helpers import insert_status
from flip_api.utils.constants import IMAGING_CREDENTIALS_TEMPLATE_NAME, IMAGING_PROJECT_ACCESS_TEMPLATE_NAME
from flip_api.utils.constants import IMAGING_INVITE_TEMPLATE_NAME, IMAGING_PROJECT_ACCESS_TEMPLATE_NAME
from flip_api.utils.encryption import decrypt
from flip_api.utils.logger import logger

Expand Down Expand Up @@ -87,33 +87,35 @@ def handle_imaging_task_completed(task: TrustTask, db: Session) -> None:
sesv2 = boto3.client("sesv2", region_name=get_settings().AWS_REGION)
sender_email = get_settings().AWS_SES_SENDER_EMAIL_ADDRESS

# Send credential emails to newly created users
# Send invite emails to newly created users. The email carries a host-less "set your own
# password" link (an XNAT alias-token path), never a password (FLIP-PT-079). The link is
# decrypted here only to place it in the email — no standing credential is ever transmitted.
for user in imaging_project.created_users:
try:
decrypted_password = decrypt(user.encrypted_password)
setup_path = decrypt(user.encrypted_setup_path)

template_data = ISesTemplateData(
trust_name=trust_name,
project_name=imaging_project.name,
project_id=project_id,
username=user.username,
password=decrypted_password,
setup_path=setup_path,
)

sesv2.send_email(
FromEmailAddress=sender_email,
Destination={"ToAddresses": [user.email]},
Content={
"Template": {
"TemplateName": IMAGING_CREDENTIALS_TEMPLATE_NAME,
"TemplateName": IMAGING_INVITE_TEMPLATE_NAME,
"TemplateData": json.dumps(template_data.model_dump(mode="json"), default=str),
}
},
)
logger.info(f"Sent XNAT credentials email to {user.email} for project '{imaging_project.name}'")
logger.info(f"Sent XNAT invite email to {user.email} for project '{imaging_project.name}'")

except Exception as e:
logger.error(f"Failed to send credentials email to {user.email}: {e}")
logger.error(f"Failed to send invite email to {user.email}: {e}")

# Send project access notifications to existing users (no password)
for added_user in imaging_project.added_users:
Expand Down
3 changes: 2 additions & 1 deletion flip-api/src/flip_api/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@

# AWS SES email templates
ACCESS_REQUEST_TEMPLATE_NAME = "flip-access-request"
IMAGING_CREDENTIALS_TEMPLATE_NAME = "flip-xnat-credentials"
# Invite email carrying a host-less "set your own password" link — never a password (FLIP-PT-079).
IMAGING_INVITE_TEMPLATE_NAME = "flip-xnat-invite"
IMAGING_PROJECT_ACCESS_TEMPLATE_NAME = "flip-xnat-added-to-project"


Expand Down
14 changes: 7 additions & 7 deletions flip-api/tests/integration/test_ses_round_trips.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from flip_api.private_services.imaging_notifications import handle_imaging_task_completed
from flip_api.utils.constants import (
ACCESS_REQUEST_TEMPLATE_NAME,
IMAGING_CREDENTIALS_TEMPLATE_NAME,
IMAGING_INVITE_TEMPLATE_NAME,
IMAGING_PROJECT_ACCESS_TEMPLATE_NAME,
)
from flip_api.utils.encryption import encrypt
Expand Down Expand Up @@ -110,23 +110,23 @@ def _seed_completed_imaging_task(session, trust_id: UUID, project_id: UUID) -> T
"""Create a CREATE_IMAGING task with a populated result block.

Result mirrors what trust-api returns when an XNAT project is created:
one newly created user (gets a credentials email) and one already-
existing user being added to the project (gets a project-access
one newly created user (gets an invite email with a set-password link) and
one already-existing user being added to the project (gets a project-access
notification).
"""
encrypted_password = encrypt("hunter2-the-password") # pragma: allowlist secret
encrypted_setup_path = encrypt("/app/template/XDATScreen_UpdateUser.vm?a=alias&s=secret")
payload = {"project_id": str(project_id)}
# The result schema is the trust-side ``ICreatedImagingProject``, which the
# parser deserialises with ``ID`` -> ``imaging_project_id``. Both
# ``created_users`` and ``added_users`` need a structured user shape with
# email + (encrypted) password where applicable.
# email + (encrypted) setup path where applicable.
result = {
"ID": str(uuid4()),
"name": "ICU-Imaging-Project",
"created_users": [
{
"username": "newbie@example.com",
"encrypted_password": encrypted_password,
"encrypted_setup_path": encrypted_setup_path,
"email": "newbie@example.com",
}
],
Expand Down Expand Up @@ -164,7 +164,7 @@ def test_handle_imaging_task_sends_one_email_per_user_and_persists_status(
handle_imaging_task_completed(task, session)

template_names = [c["Content"]["Template"]["TemplateName"] for c in ses_send_email_recorder]
assert template_names.count(IMAGING_CREDENTIALS_TEMPLATE_NAME) == 1
assert template_names.count(IMAGING_INVITE_TEMPLATE_NAME) == 1
assert template_names.count(IMAGING_PROJECT_ACCESS_TEMPLATE_NAME) == 1

# Status row: persisted exactly once.
Expand Down
Loading
Loading