mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 07:52:13 +00:00
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:
1 parent
5be134e0e6
commit
ac9c33d43f
6 files changed
+98
-19
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in new issue
Block a user