diff --git a/backend/src/lib/tree-invitations.ts b/backend/src/lib/tree-invitations.ts index d96ac70..4c4a986 100644 --- a/backend/src/lib/tree-invitations.ts +++ b/backend/src/lib/tree-invitations.ts @@ -96,10 +96,15 @@ export async function acceptInvitation(prisma: PrismaClient, invitationId: strin } // Connexion OIDC avec un email vérifié par le fournisseur : les invitations en attente à cette adresse sont -// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe. +// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe, et +// seulement si c'est l'adresse du compte : une identité déjà reliée dont l'email a changé chez le fournisseur +// ne prend pas les invitations faites à sa nouvelle adresse (elle passe par le lien). export async function claimInvitationsForVerifiedEmail(prisma: PrismaClient, userId: string, verifiedEmail: string): Promise { const email = normalizeEmail(verifiedEmail) const invitations = await prisma.treeInvitation.findMany({ where: { email, ...pendingInvitationWhere() }, select: { id: true } }) + if (!invitations.length) return [] + const account = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }) + if (!account || normalizeEmail(account.email) !== email) return [] const joined: string[] = [] for (const invitation of invitations) { const result = await acceptInvitation(prisma, invitation.id, userId, email) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index b8c21fd..95ef14b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -178,7 +178,13 @@ export const authRoutes: FastifyPluginAsync = async (app) => { let joinedTreeId: string | null = null if (invitation) { const accepted = await acceptInvitation(app.prisma, invitation.id, user.id, user.email) - if (accepted.ok) joinedTreeId = accepted.treeId + if (accepted.ok) { + joinedTreeId = accepted.treeId + } else if (!(await isRegistrationOpen(app.prisma))) { + // Invitation annulée ou servie entre-temps, sur une instance fermée : pas de compte sans arbre + await app.prisma.user.delete({ where: { id: user.id } }) + return reply.code(410).send({ error: 'invitation_invalid' }) + } } return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true }, joinedTreeId }) diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 9b8c227..1383c55 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client' -import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' import path from 'node:path' import { randomUUID } from 'node:crypto' import { mkdir, writeFile } from 'node:fs/promises' @@ -18,7 +18,7 @@ import { } from '../lib/immich.js' import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js' import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' -import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js' +import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js' // Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la // vignette devient son portrait. Chaque arbre porte sa propre connexion Immich : une adresse @@ -97,6 +97,21 @@ function buildAvatarRelativePath(treeId: string, personId: string, extension: st return path.posix.join(treeId, personId, `immich-${randomUUID()}${extension}`) } + +async function membershipRole(app: FastifyInstance, userId: string, treeId: string) { + const membership = await app.prisma.treeMembership.findUnique({ + where: { treeId_userId: { treeId, userId } }, + select: { role: true, tree: { select: { deletedAt: true } } }, + }) + return membership && !membership.tree.deletedAt ? membership.role : null +} + +// Immich passe par la clé du propriétaire, qui ouvre toute sa photothèque, pas seulement les photos de l'arbre : +// réservé au propriétaire, jamais aux co-gestionnaires (issue #145) +async function requireImmichOwner(app: FastifyInstance, userId: string, treeId: string): Promise { + return (await membershipRole(app, userId, treeId)) === 'owner' +} + export const immichRoutes: FastifyPluginAsync = async (app) => { // Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre. // Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota, @@ -111,7 +126,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { personId: z.string().refine(isImmichPersonId).optional(), }).safeParse(request.query) if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' }) - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) const client = await getTreeImmichClient(app.prisma, params.data.id) if (!client) return reply.code(503).send({ error: 'immich_not_configured' }) try { @@ -124,7 +139,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' }) const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params) if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) const client = await getTreeImmichClient(app.prisma, params.data.id) if (!client) return reply.code(503).send({ error: 'immich_not_configured' }) try { @@ -154,9 +169,15 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + const role = await membershipRole(app, request.actor.userId, params.data.id) + if (role !== 'owner' && role !== 'admin') { return reply.code(403).send({ error: 'forbidden' }) } + // Co-gestionnaire : la clé Immich est celle du propriétaire et ouvre toute sa photothèque. Il ne voit ni ne + // règle rien ; pour l'interface, Immich n'est pas configuré + if (role !== 'owner') { + return reply.send({ config: null, instances: [], ownerOnly: true }) + } const config = await app.prisma.treeImmichConfig.findUnique({ where: { treeId: params.data.id }, @@ -184,7 +205,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -253,7 +274,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -287,7 +308,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -310,7 +331,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -339,7 +360,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_immich_person_id' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -381,7 +402,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -407,7 +428,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: payload.success ? 'invalid_params' : 'invalid_immich_person_id' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } @@ -565,7 +586,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_params' }) } - if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) { return reply.code(403).send({ error: 'forbidden' }) } diff --git a/backend/src/routes/managers.ts b/backend/src/routes/managers.ts index a627f77..ee038be 100644 --- a/backend/src/routes/managers.ts +++ b/backend/src/routes/managers.ts @@ -16,7 +16,9 @@ import { // Co-gestion d'un arbre (issue #145). Un co-gestionnaire est un membre « admin » : il fait tout ce que fait le // propriétaire, sauf inviter ou retirer un co-gestionnaire et supprimer l'arbre (DELETE /trees/:id, réservé au -// propriétaire). Les droits sont relus en base à chaque requête : un co-gestionnaire retiré perd la main aussitôt. +// propriétaire), ni Immich, qui passe par la clé du propriétaire (routes/immich.ts). Les droits sont relus en base à +// chaque requête : un co-gestionnaire retiré perd la main aussitôt. Il a pu voir le mot de passe de partage : +// l'écran de retrait propose de le changer. const treeIdParamSchema = z.object({ id: z.string().min(1) }) const invitationParamsSchema = z.object({ id: z.string().min(1), invitationId: z.string().min(1) }) @@ -175,8 +177,16 @@ export const managerRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } - const removed = await app.prisma.treeMembership.deleteMany({ - where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' }, + // Un accès retenu par le mot de passe de partage (user_tree_accesses) part aussi : sinon le compte retiré + // lirait encore l'arbre + const removed = await app.prisma.$transaction(async (tx) => { + const deleted = await tx.treeMembership.deleteMany({ + where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' }, + }) + if (deleted.count === 1) { + await tx.userTreeAccess.deleteMany({ where: { treeId: params.data.id, userId: params.data.userId } }) + } + return deleted }) if (removed.count !== 1) return reply.code(404).send({ error: 'manager_not_found' }) await audit(app, params.data.id, user.userId, leaving ? 'manager_left' : 'manager_removed', { userId: params.data.userId }) diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index b12d023..0f1afc2 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -181,7 +181,8 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => { if ('error' in result) { return redirectToApp(reply, returnTo, `oidc_error=${result.error}`) } - // Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur + // Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur (et + // devant être celle du compte, voir claimInvitationsForVerifiedEmail) if (identity.email && identity.emailVerified) { await claimInvitationsForVerifiedEmail(app.prisma, result.userId, identity.email) } diff --git a/backend/test/co-gestion.db.test.ts b/backend/test/co-gestion.db.test.ts index abfb9d7..9726660 100644 --- a/backend/test/co-gestion.db.test.ts +++ b/backend/test/co-gestion.db.test.ts @@ -177,6 +177,32 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { await call('DELETE', `/trees/${treeId}/managers/${created.body.user.id}`, owner.token) }) + it("un co-gestionnaire n'utilise jamais Immich, qui passe par la clé du propriétaire", async () => { + const co = await account('immich') + const { token } = await invite(co.email) + expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200) + const config = await call('GET', `/trees/${treeId}/immich/config`, co.token) + expect(config.status).toBe(200) + expect(config.body).toEqual({ config: null, instances: [], ownerOnly: true }) + for (const url of ['/immich/assets?page=1&q=plage', '/immich/people', '/immich/status', '/immich/assets/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b/thumbnail', '/immich/thumbnail/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b']) { + expect((await call('GET', `/trees/${treeId}${url}`, co.token)).status).toBe(403) + } + expect((await call('PUT', `/trees/${treeId}/immich/config`, co.token, { instanceId: '0123456789abcdef', apiKey: 'y' })).status).toBe(403) + expect((await call('DELETE', `/trees/${treeId}/immich/config`, co.token)).status).toBe(403) + await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token) + }) + + it("retiré, un co-gestionnaire qui avait ouvert l'arbre par le mot de passe de partage ne le lit plus", async () => { + const co = await account('partage') + const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, co.token, { password: 'partage-demo-1' }) + expect(unlocked.status).toBe(200) + expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(200) + const { token } = await invite(co.email) + expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200) + expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200) + expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(403) + }) + describe('OIDC', () => { let address = 0 async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) { @@ -212,6 +238,16 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => { expect(await prisma.user.findUnique({ where: { email } })).toBeNull() }) + it("identité déjà reliée dont l'email a changé chez le fournisseur : pas d'invitation prise sans le lien", async () => { + const subject = `sub-change-${run}` + expect((await oidcLogin({ subject, email: mail('avant'), emailVerified: true })).get('oidc')).toBeTruthy() + const target = mail('cible') + await invite(target) + expect((await oidcLogin({ subject, email: target, emailVerified: true })).get('oidc')).toBeTruthy() + const user = await prisma.user.findUnique({ where: { email: mail('avant') }, select: { id: true } }) + expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull() + }) + it("email non vérifié sur un compte déjà relié : l'invitation reste en attente", async () => { const email = mail('oidc-relie') const subject = `sub-relie-${run}`