Co-gestion (#145), revue de droits : Immich réservé au propriétaire, retrait qui coupe aussi l'accès par le partage, rattachement OIDC sur l'adresse du compte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 16:57:11 +01:00
1 parent 5be134e0e6
commit ac9c33d43f
6 files changed
+98 -19

No files matched your search

+6 -1
View File
@@ -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<string[]> {
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)
+7 -1
View File
@@ -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 })
+34 -13
View File
@@ -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<boolean> {
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' })
}
+13 -3
View File
@@ -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 })
+2 -1
View File
@@ -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)
}
+36
View File
@@ -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}`