This commit is contained in:
Thomas Ramé
2026-09-28 12:42:57 +02:00
parent ad3d012e28
commit 9ec3f4fae2
7 changed files with 126 additions and 122 deletions
+3 -30
View File
@@ -2071,14 +2071,6 @@ class DocumentViewSet(
'Please set the document access to "Restricted" before encrypting.'
})
# Prevent encryption if there are pending invitations
if document.invitations.exists():
raise drf.exceptions.ValidationError({
'non_field_errors':
'Cannot encrypt a document with pending invitations. '
'Please resolve all invitations before encrypting.'
})
# Validate that we have encrypted symmetric keys for all users with access.
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC sub (suite_user_id).
# Values may be a wrapped key (validated) or explicit null (pending —
@@ -2686,15 +2678,9 @@ class InvitationViewset(
def perform_create(self, serializer):
"""Save invitation to a document then send an email to the invited user."""
# Prevent invitation creation for encrypted documents
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
if document.is_encrypted:
raise drf.exceptions.ValidationError({
'non_field_errors':
'Cannot create invitations for encrypted documents. '
'All invitations must be resolved before encrypting a document.'
})
# On an encrypted document the invitee signs up without a key: their
# invitation becomes a pending access, accepted once they have enabled
# encryption.
invitation = serializer.save()
invitation.document.send_invitation_email(
@@ -2704,19 +2690,6 @@ class InvitationViewset(
self.request.user.language or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
"""Update an invitation to a document."""
# Prevent invitation updates for encrypted documents
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
if document.is_encrypted:
raise drf.exceptions.ValidationError({
'non_field_errors':
'Cannot update invitations for encrypted documents. '
'All invitations must be resolved before encrypting a document.'
})
return super().perform_update(serializer)
class DocumentAskForAccessViewSet(
drf.mixins.ListModelMixin,
@@ -0,0 +1,86 @@
"""
Invitations on encrypted documents: an invitee has no account, hence no
encryption key, so their invitation turns into a pending access (no wrapped
key) when they sign up, like any member added without encryption.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def _owned_encrypted_document(owner):
document = factories.DocumentFactory(link_reach="restricted", is_encrypted=True)
factories.UserDocumentAccessFactory(
document=document,
user=owner,
role="owner",
encrypted_document_symmetric_key_for_user="d3JhcHBlZA==",
encryption_public_key_version=1,
)
return document
def test_api_document_encryption_invitations_do_not_block_encrypting():
"""A document with pending invitations can be encrypted."""
owner = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
factories.UserDocumentAccessFactory(document=document, user=owner, role="owner")
factories.InvitationFactory(document=document, issuer=owner)
client = APIClient()
client.force_login(owner)
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/encrypt/",
{
"content": "ZW5jcnlwdGVk",
"encryptedSymmetricKeyPerUser": {owner.sub: "d3JhcHBlZA=="},
"encryptionPublicKeyVersionPerUser": {owner.sub: 1},
},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.is_encrypted is True
assert document.invitations.count() == 1
def test_api_document_encryption_invitations_create_and_update():
"""Invitations can be created and updated on an encrypted document."""
owner = factories.UserFactory()
document = _owned_encrypted_document(owner)
client = APIClient()
client.force_login(owner)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
{"email": "newcomer@example.com", "role": "reader"},
format="json",
)
assert response.status_code == 201
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/invitations/{response.json()['id']}/",
{"role": "editor"},
format="json",
)
assert response.status_code == 200
assert models.Invitation.objects.get(document=document).role == "editor"
def test_api_document_encryption_invitations_become_pending_accesses():
"""Signing up turns the invitation into an access with no wrapped key."""
owner = factories.UserFactory()
document = _owned_encrypted_document(owner)
factories.InvitationFactory(
document=document, issuer=owner, email="newcomer@example.com"
)
newcomer = factories.UserFactory(email="newcomer@example.com")
access = models.DocumentAccess.objects.get(document=document, user=newcomer)
assert access.encrypted_document_symmetric_key_for_user is None
@@ -19,13 +19,13 @@ export const DecryptionFailurePanel = ({ failure }: Props) => {
key_unavailable: {
title: t('This document was shared with a previous key'),
description: t(
'It was shared with you under an encryption key you no longer have, most likely from before you reset your encryption. Ask the document owner to remove you from its members and add you again.',
'That key is no longer on your account, most likely because you reset your encryption. Ask the document owner to remove you from its members and add you again.',
),
},
key_mismatch: {
title: t('This document was encrypted with a different key'),
description: t(
"The copy of this document's key stored for you cannot be opened with your encryption key. Ask the document owner to remove you from its members and add you again.",
'Your current encryption key cannot open it. Ask the document owner to remove you from its members and add you again.',
),
},
content_integrity: {
@@ -157,7 +157,9 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
const effectiveReach = getDocLinkReach(doc);
const isRestricted = effectiveReach === LinkReach.RESTRICTED;
const hasPendingInvitations = !!invitationsData && invitationsData.count > 0;
// Invitees have no account yet: their invitation becomes a pending access
// when they sign up, like a member without encryption.
const invitationCount = invitationsData?.count ?? 0;
// Fetch public keys from the encryption service to check who has encryption enabled
const [publicKeysMap, setPublicKeysMap] = useState<
@@ -212,12 +214,14 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
[membersWithoutKey, user?.suite_user_id],
);
const pendingCount = othersWithoutKey.length + invitationCount;
const hasEncryptionKeys = !!encryptionSettings;
// Members with no public key will be written to the backend as
// pending (`null` wrapped key). They'll see the document in their
// listings but won't be able to decrypt until a validated collaborator
// accepts them from the share dialog. This no longer blocks
// listings but won't be able to decrypt until the owner, opening the
// document, gives them its key. This no longer blocks
// encryption — only the degenerate case where NOBODY has a key does.
const hasAnyPublicKey =
accesses === undefined || accesses.length === 0
@@ -226,11 +230,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
(a) => a.user?.suite_user_id && !!publicKeysMap[a.user.suite_user_id],
);
const canEncrypt =
hasEncryptionKeys &&
isRestricted &&
!hasPendingInvitations &&
hasAnyPublicKey;
const canEncrypt = hasEncryptionKeys && isRestricted && hasAnyPublicKey;
const handleClose = () => {
if (isPending) {
@@ -388,9 +388,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
}),
);
}
if (hasPendingInvitations) {
blockers.push(t('Pending invitations must be resolved first'));
}
return (
<Modal
@@ -451,11 +448,11 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
</Alert>
)}
{!isError && blockers.length === 0 && othersWithoutKey.length > 0 && (
{!isError && blockers.length === 0 && pendingCount > 0 && (
<Alert type={VariantType.WARNING}>
{t(
'{{count}} collaborator(s) have not enabled encryption yet. They will be added as pending and get access once they enable it.',
{ count: othersWithoutKey.length },
'{{count}} collaborators have not enabled encryption yet. They will be added as pending and get access once they enable it.',
{ count: pendingCount },
)}
</Alert>
)}
@@ -122,31 +122,16 @@ export const DocShareAddMemberList = ({
};
if (isInvitationMode) {
if (doc.is_encrypted) {
throw Object.assign(
new Error(
t(
'Only registered users with encryption enabled can be added to encrypted documents.',
),
),
{
cause: [
t(
'Only registered users with encryption enabled can be added to encrypted documents.',
),
],
data: { value: user.email, type: OptionType.INVITATION },
},
) as APIErrorUser;
}
// On an encrypted document the invitee becomes a pending member when
// they sign up, like anyone added without encryption.
return createInvitation({
...payload,
email: user.email.toLowerCase(),
});
}
// For encrypted docs, re-wrap the symmetric key for the new member via vault
// For encrypted docs, re-wrap the symmetric key for the new member via
// vault; a member without encryption is created pending (no key).
let memberEncryptedSymmetricKey: string | null = null;
let encryptionPublicKeyVersion: number | null = null;
@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -28,10 +28,7 @@ import {
} from '@/docs/doc-collaboration/vault';
import { Doc, useEncryptionAccessCopy } from '@/docs/doc-management';
import { User, useAuth } from '@/features/auth';
import {
EncryptionEmptyState,
EncryptionModalContent,
} from '@/features/docs/doc-management/components/EncryptionLayout';
import { EncryptionEmptyState } from '@/features/docs/doc-management/components/EncryptionLayout';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
@@ -425,10 +422,9 @@ const QuickSearchInviteInputSection = ({
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
const { client: vaultClient } = useVaultClient();
const [showNoKeyModal, setShowNoKeyModal] = useState(false);
// Subs of the search results that hold a registered encryption key, from
// the directory; null until known (or when the lookup failed), in which
// case nobody is refused here and the invitation itself reports.
// case nobody is marked.
const [registeredSubs, setRegisteredSubs] = useState<Set<string> | null>(
null,
);
@@ -475,7 +471,7 @@ const QuickSearchInviteInputSection = ({
[isEncrypted, registeredSubs],
);
const showEncryptedInviteWarning = useMemo(() => {
const showEncryptedInviteHint = useMemo(() => {
const users = searchUsersRawData || [];
const isEmail = isValidEmail(userQuery);
const hasEmailInUsers = users.some(
@@ -484,17 +480,6 @@ const QuickSearchInviteInputSection = ({
return isEncrypted && isEmail && !hasEmailInUsers;
}, [searchUsersRawData, userQuery, isEncrypted]);
const handleSelect = useCallback(
(user: User) => {
if (hasNoKey(user)) {
setShowNoKeyModal(true);
return;
}
onSelect(user);
},
[hasNoKey, onSelect],
);
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersRawData || [];
const isEmail = isValidEmail(userQuery);
@@ -511,7 +496,7 @@ const QuickSearchInviteInputSection = ({
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
);
const showInviteByEmail = isEmail && !hasEmailInUsers && !isEncrypted;
const showInviteByEmail = isEmail && !hasEmailInUsers;
return {
groupName: t('Search user result'),
@@ -520,12 +505,12 @@ const QuickSearchInviteInputSection = ({
? [
{
content: <DocShareModalInviteUserRow user={newUser} />,
onSelect: () => void handleSelect(newUser),
onSelect: () => void onSelect(newUser),
},
]
: undefined,
};
}, [handleSelect, searchUsersRawData, t, userQuery, isEncrypted]);
}, [onSelect, searchUsersRawData, t, userQuery]);
// On an encrypted document, a person's avatar opens their encryption
// identity (fingerprint, trust decision), registered or not.
@@ -547,7 +532,7 @@ const QuickSearchInviteInputSection = ({
return {
label: t('No encryption'),
hint: t(
'This person has not enabled encryption yet, so they cannot be added to an encrypted document.',
'This person has not enabled encryption yet. They will be added as pending and get access once they enable it, the next time the document owner opens the document.',
),
};
}
@@ -563,7 +548,7 @@ const QuickSearchInviteInputSection = ({
>
<QuickSearchGroup
group={searchUserData}
onSelect={handleSelect}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow
user={user}
@@ -572,39 +557,17 @@ const QuickSearchInviteInputSection = ({
/>
)}
/>
{showEncryptedInviteWarning && (
{showEncryptedInviteHint && (
<Text
$variation="secondary"
$size="sm"
$padding={{ horizontal: 'xs', top: '3xs' }}
>
{t(
'Only registered users with encryption enabled can be added to encrypted documents.',
'Invited people get access once they have signed up and enabled encryption, the next time the document owner opens the document.',
)}
</Text>
)}
{showNoKeyModal && (
<Modal
isOpen
closeOnClickOutside
onClose={() => setShowNoKeyModal(false)}
size={ModalSize.SMALL}
aria-label={t('Encryption required')}
>
<EncryptionModalContent
illustration="document-shield-x"
title={t('Encryption required')}
description={t(
'This person has not enabled encryption yet, so the document cannot be shared with them. Ask them to enable encryption first.',
)}
actions={
<Button fullWidth onClick={() => setShowNoKeyModal(false)}>
{t('Understood')}
</Button>
}
/>
</Modal>
)}
</Box>
);
};
@@ -523,6 +523,7 @@
"en": {
"translation": {
"Contains {{count}} sub-documents_one": "Contains {{count}} sub-document",
"{{count}} collaborators have not enabled encryption yet. They will be added as pending and get access once they enable it._one": "{{count}} collaborator has not enabled encryption yet. They will be added as pending and get access once they enable it.",
"Share with {{count}} users_one": "Share with {{count}} user",
"Shared with {{count}} users_many": "Shared with {{count}} users",
"Shared with {{count}} users_one": "Shared with {{count}} user",
@@ -1007,10 +1008,11 @@
"Encrypt": "Chiffrer",
"The document and its attachments will be encrypted end-to-end. Only people you share it with will be able to access its contents.": "Le document et ses pièces jointes seront chiffrés de bout en bout. Seules les personnes avec qui vous le partagez pourront y accéder.",
"The document will be decrypted and stored in plain text on the server.": "Le document sera déchiffré et stocké en clair sur le serveur.",
"{{count}} collaborator(s) have not enabled encryption yet. They will be added as pending and get access once they enable it.": "{{count}} collaborateur(s) n'ont pas encore activé le chiffrement. Ils seront ajoutés en attente et auront accès dès qu'ils l'auront activé.",
"{{count}} collaborators have not enabled encryption yet. They will be added as pending and get access once they enable it._many": "{{count}} personnes n'ont pas encore activé le chiffrement. Elles seront ajoutées en attente et auront accès dès qu'elles l'auront activé.",
"{{count}} collaborators have not enabled encryption yet. They will be added as pending and get access once they enable it._one": "{{count}} personne n'a pas encore activé le chiffrement. Elle sera ajoutée en attente et aura accès dès qu'elle l'aura activé.",
"{{count}} collaborators have not enabled encryption yet. They will be added as pending and get access once they enable it._other": "{{count}} personnes n'ont pas encore activé le chiffrement. Elles seront ajoutées en attente et auront accès dès qu'elles l'auront activé.",
"You must enable encryption from your account menu first": "Vous devez d'abord activer le chiffrement depuis le menu de votre compte",
"Document must be set to private (currently {{reach}})": "Le document doit être privé (actuellement {{reach}})",
"Pending invitations must be resolved first": "Les invitations en attente doivent d'abord être traitées",
"Encrypted document": "Document chiffré",
"Encryption service unavailable": "Service de chiffrement indisponible",
"The encryption service could not be loaded. Check your connection and try again.": "Le service de chiffrement n'a pas pu être chargé. Vérifiez votre connexion et réessayez.",
@@ -1025,17 +1027,14 @@
"The document owner is removing encryption from this document. Please wait.": "Le propriétaire du document est en train d'en retirer le chiffrement. Veuillez patienter.",
"This document was encrypted with a different key": "Ce document a été chiffré avec une autre clé",
"No encryption": "Pas de chiffrement",
"Encryption required": "Chiffrement requis",
"This person has not enabled encryption yet, so the document cannot be shared with them. Ask them to enable encryption first.": "Cette personne n'a pas encore activé le chiffrement, le document ne peut donc pas être partagé avec elle. Demandez-lui d'activer le chiffrement d'abord.",
"Understood": "Compris",
"Action needed": "Action requise",
"Accept": "Accepter",
"Accepting…": "Acceptation…",
"Encryption settings": "Paramètres de chiffrement",
"Verify the identity of {{name}}": "Vérifier l'identité de {{name}}",
"This document was shared with a previous key": "Ce document a été partagé avec une ancienne clé",
"It was shared with you under an encryption key you no longer have, most likely from before you reset your encryption. Ask the document owner to remove you from its members and add you again.": "Il vous a été partagé avec une clé de chiffrement que vous n'avez plus, sans doute d'avant la réinitialisation de votre chiffrement. Demandez au propriétaire du document de vous retirer de ses membres puis de vous ajouter à nouveau.",
"The copy of this document's key stored for you cannot be opened with your encryption key. Ask the document owner to remove you from its members and add you again.": "La copie de la clé de ce document enregistrée pour vous ne peut pas être ouverte avec votre clé de chiffrement. Demandez au propriétaire du document de vous retirer de ses membres puis de vous ajouter à nouveau.",
"That key is no longer on your account, most likely because you reset your encryption. Ask the document owner to remove you from its members and add you again.": "Cette clé n'est plus sur votre compte, sans doute parce que vous avez réinitialisé votre chiffrement. Demandez au propriétaire du document de vous retirer de ses membres puis de vous ajouter à nouveau.",
"Your current encryption key cannot open it. Ask the document owner to remove you from its members and add you again.": "Votre clé de chiffrement actuelle ne permet pas de l'ouvrir. Demandez au propriétaire du document de vous retirer de ses membres puis de vous ajouter à nouveau.",
"This document cannot be decrypted": "Ce document ne peut pas être déchiffré",
"Your key is correct, but the stored content is damaged or was altered, so it cannot be trusted. Contact the owner of this document or your support.": "Votre clé est la bonne, mais le contenu enregistré est endommagé ou a été modifié : il n'est pas fiable. Contactez le propriétaire de ce document ou votre support.",
"This document could not be decrypted": "Ce document n'a pas pu être déchiffré",
@@ -1046,10 +1045,11 @@
"Encryption enabled": "Chiffrement activé",
"Waiting for them to enable encryption": "En attente de l'activation du chiffrement par cette personne",
"Waiting for encryption": "En attente de chiffrement",
"This person has not enabled encryption yet. They will be added as pending and get access once they enable it, the next time the document owner opens the document.": "Cette personne n'a pas encore activé le chiffrement. Elle sera ajoutée en attente et aura accès une fois le chiffrement activé, la prochaine fois que le propriétaire du document l'ouvrira.",
"Invited people get access once they have signed up and enabled encryption, the next time the document owner opens the document.": "Les personnes invitées auront accès une fois inscrites et leur chiffrement activé, la prochaine fois que le propriétaire du document l'ouvrira.",
"This document was encrypted before you enabled encryption, so its key could not be shared with you. Enable encryption on your account: you will get access the next time the document owner opens it.": "Ce document a été chiffré avant que vous activiez le chiffrement, sa clé n'a donc pas pu vous être partagée. Activez le chiffrement sur votre compte : vous aurez accès la prochaine fois que le propriétaire du document l'ouvrira.",
"This document was encrypted before you enabled encryption, so its key could not be shared with you then. You will get access the next time the document owner opens it.": "Ce document a été chiffré avant que vous activiez le chiffrement, sa clé n'a donc pas pu vous être partagée à ce moment-là. Vous aurez accès la prochaine fois que le propriétaire du document l'ouvrira.",
"Added before they enabled encryption. They get access once they have, the next time the document owner opens it.": "Ajouté avant d'avoir activé le chiffrement. Cette personne aura accès une fois le chiffrement activé, la prochaine fois que le propriétaire du document l'ouvrira.",
"This person has not enabled encryption yet, so they cannot be added to an encrypted document.": "Cette personne n'a pas encore activé le chiffrement, elle ne peut donc pas être ajoutée à un document chiffré."
"Added before they enabled encryption. They get access once they have, the next time the document owner opens it.": "Ajouté avant d'avoir activé le chiffrement. Cette personne aura accès une fois le chiffrement activé, la prochaine fois que le propriétaire du document l'ouvrira."
}
},
"it": {