From f8bde0bca095334d563332d68d4c7bc0ffbb6bea Mon Sep 17 00:00:00 2001 From: BatLeDev Date: Mon, 14 Sep 2026 16:32:57 +0200 Subject: [PATCH 1/8] fix(identities): clean up partner permissions, limits and directories on identity webhooks - bump @data-fair/lib-express to 1.26.0 (secret in the x-secret-key header) - rename: the name of a partner organization in the permissions of other owners' processings follows, and so does the limits document - an organization update carrying partners withdraws the partner permissions granted to organizations that are no longer partners - delete: the processings of the identity are removed with their runs and their directory on disk (same cleanup as DELETE /processings/:id, the directory was left behind), the limits too; a deleted organization is pulled from the partner permissions of others; created/updated only keep the id of a deleted user - created/updated name is now optional in the processing schema - identities.api.spec covers renames, the end of a partnership, the user and organization deletions --- api/package.json | 2 +- api/src/misc/routers/identities.ts | 69 ++++++++++++----- api/types/processing/schema.js | 3 +- package-lock.json | 8 +- .../processings/identities.api.spec.ts | 74 +++++++++++++++++++ 5 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 tests/features/processings/identities.api.spec.ts diff --git a/api/package.json b/api/package.json index f57e6c1f..288b0431 100644 --- a/api/package.json +++ b/api/package.json @@ -21,7 +21,7 @@ "@types/ws": "^8.5.12" }, "dependencies": { - "@data-fair/lib-express": "^1.25.0", + "@data-fair/lib-express": "^1.26.0", "@data-fair/lib-node": "^2.13.3", "@data-fair/lib-node-registry": "^0.7.1", "@data-fair/lib-utils": "^1.13.1", diff --git a/api/src/misc/routers/identities.ts b/api/src/misc/routers/identities.ts index f6051564..ba1eee5f 100644 --- a/api/src/misc/routers/identities.ts +++ b/api/src/misc/routers/identities.ts @@ -1,9 +1,10 @@ -// Webhooks for Simple Directory +// Webhooks for Simple Directory: keep the copies of identity data in sync and clean up after a deletion import Debug from 'debug' import { createIdentitiesRouter } from '@data-fair/lib-express/identities/index.js' import config from '#config' import mongo from '#mongo' +import { deleteProcessing } from '../../runs/service.ts' const debug = Debug('webhooks-simple-directory') @@ -20,32 +21,43 @@ export default createIdentitiesRouter( // onUpdate async (identity) => { debug('Incoming sd webhook for update', identity) + const { type, id, name } = identity // Update owner name - await updateAllCollections( - { 'owner.type': identity.type, 'owner.id': identity.id }, - { $set: { 'owner.name': identity.name } } - ) + await updateAllCollections({ 'owner.type': type, 'owner.id': id }, { $set: { 'owner.name': name } }) + await mongo.limits.updateMany({ type, id }, { $set: { name } }) - if (identity.type === 'user') { + if (type === 'user') { // Update created/updated name await Promise.all([ - updateAllCollections( - { 'created.id': identity.id }, - { $set: { 'created.name': identity.name } } - ), - updateAllCollections( - { 'updated.id': identity.id }, - { $set: { 'updated.name': identity.name } } - ) + updateAllCollections({ 'created.id': id }, { $set: { 'created.name': name } }), + updateAllCollections({ 'updated.id': id }, { $set: { 'updated.name': name } }) ]) } + if (type === 'organization') { + // the organization as a partner granted permissions on the processings of others + await mongo.processings.updateMany( + { permissions: { $elemMatch: { 'target.type': 'partner', 'target.organization.id': id } } }, + { $set: { 'permissions.$[p].target.organization.name': name } }, + { arrayFilters: [{ 'p.target.type': 'partner', 'p.target.organization.id': id }] } + ) + // partner permissions are only meaningful inside a partnership: the directory sends the complete + // list of partners, what is not in it was withdrawn + if (identity.partners) { + const partnerIds = identity.partners.map(p => p.id) + await mongo.processings.updateMany( + { 'owner.type': 'organization', 'owner.id': id, permissions: { $elemMatch: { 'target.type': 'partner', 'target.organization.id': { $nin: partnerIds } } } }, + { $pull: { permissions: { 'target.type': 'partner', 'target.organization.id': { $nin: partnerIds } } } } as any + ) + } + } + // If the identity has departments, update the department names in processings and runs if (identity.departments) { const departmentUpdates = identity.departments.map(department => updateAllCollections( - { 'owner.type': identity.type, 'owner.id': identity.id, 'owner.department': department.id }, + { 'owner.type': type, 'owner.id': id, 'owner.department': department.id }, { $set: { 'owner.departmentName': department.name } } ) ) @@ -56,10 +68,29 @@ export default createIdentitiesRouter( // onDelete async (identity) => { debug('Incoming sd webhook for delete', identity) - // Delete all processings and runs for this identity - const filter = { 'owner.type': identity.type, 'owner.id': identity.id } - await mongo.processings.deleteMany(filter) - await mongo.runs.deleteMany(filter) + const { type, id } = identity + + // Delete all processings for this identity, with their runs and their directory + for await (const processing of mongo.processings.find({ 'owner.type': type, 'owner.id': id })) { + await mongo.processings.deleteOne({ _id: processing._id }) + await deleteProcessing(mongo, processing) + } + await mongo.runs.deleteMany({ 'owner.type': type, 'owner.id': id }) + await mongo.limits.deleteMany({ type, id }) + + if (type === 'organization') { + await mongo.processings.updateMany( + { permissions: { $elemMatch: { 'target.type': 'partner', 'target.organization.id': id } } }, + { $pull: { permissions: { 'target.type': 'partner', 'target.organization.id': id } } } as any + ) + } + if (type === 'user') { + // what the user authored on the processings of others keeps the trace of the action without the name + await Promise.all([ + updateAllCollections({ 'created.id': id }, { $unset: { 'created.name': 1 } }), + updateAllCollections({ 'updated.id': id }, { $unset: { 'updated.name': 1 } }) + ]) + } // When departments are deleted, do nothing } ) diff --git a/api/types/processing/schema.js b/api/types/processing/schema.js index 1b67c5ae..6aa5360e 100644 --- a/api/types/processing/schema.js +++ b/api/types/processing/schema.js @@ -48,9 +48,9 @@ export default { created: { type: 'object', additionalProperties: false, + // no name once the user is deleted from the directory required: [ 'id', - 'name', 'date' ], readOnly: true, @@ -128,7 +128,6 @@ export default { readOnly: true, required: [ 'id', - 'name', 'date' ], properties: { diff --git a/package-lock.json b/package-lock.json index 144f6f79..57071222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ }, "api": { "dependencies": { - "@data-fair/lib-express": "^1.25.0", + "@data-fair/lib-express": "^1.26.0", "@data-fair/lib-node": "^2.13.3", "@data-fair/lib-node-registry": "^0.7.1", "@data-fair/lib-utils": "^1.13.1", @@ -500,9 +500,9 @@ } }, "node_modules/@data-fair/lib-express": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@data-fair/lib-express/-/lib-express-1.25.0.tgz", - "integrity": "sha512-Haqd+OUDgFcduVX0K+xs0dP4+FoS1aE2LDTzdoed2wHZO/WwJ60c/e2QtdijzWH04sRarzC9ujKJQxnrBScA6A==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@data-fair/lib-express/-/lib-express-1.26.0.tgz", + "integrity": "sha512-sSPl09vhgaMfAIqZXVcbcCWT6T8G07R/5BBWqunVG1M9PeEWA0R2gShikQmNJM4kKE6Jqe2e0qmXaqzqcNPflg==", "license": "MIT", "dependencies": { "@data-fair/lib-common-types": "^1.7.1", diff --git a/tests/features/processings/identities.api.spec.ts b/tests/features/processings/identities.api.spec.ts new file mode 100644 index 00000000..a93764b7 --- /dev/null +++ b/tests/features/processings/identities.api.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test' +import fs from 'node:fs' +import path from 'node:path' +import { axios, anonymousAx, axiosAuth, apiUrl, clean } from '../../support/axios.ts' +import { publishFixturePlugin } from '../../support/registry.ts' + +// identity webhooks are internal calls: simple-directory reaches the API directly, not through the proxy +const axIdentities = axios({ baseURL: apiUrl, headers: { 'x-secret-key': 'secret-identities' } }) +const rawProcessing = async (id: string) => (await anonymousAx.get(`${apiUrl}/api/v1/test-env/raw-processing/${id}`, { validateStatus: () => true })) +const processingsDir = path.resolve(import.meta.dirname, '../../../data/development/processings') + +const seedProcessing = async () => { + const plugin = await publishFixturePlugin({ name: '@data-fair/processing-hello-world', version: '1.2.2' }) + const admin = await axiosAuth({ email: 'test_admin1@test.com', org: 'test_org1' }) + const processing = (await admin.post('/api/v1/processings', { title: 'Identities processing', plugin: plugin.pluginId })).data + await admin.patch(`/api/v1/processings/${processing._id}`, { + permissions: [ + { profile: 'read', target: { type: 'partner', organization: { name: 'Test Org 2', id: 'test_org2' }, roles: ['admin'] } }, + { profile: 'read', target: { type: 'partner', organization: { name: 'Test Org 3', id: 'test_org3' }, roles: ['admin'] } }, + { profile: 'read', target: { type: 'userEmail', email: 'test_alone@test.com' } } + ] + }) + return processing +} + +test.describe('identity webhooks', () => { + test.beforeEach(clean) + test.afterAll(clean) + + test('should follow renames and the end of a partnership', async () => { + const processing = await seedProcessing() + + await axIdentities.post('/api/identities/user/test_admin1', { name: 'Renamed Admin', organizations: [{ id: 'test_org1', role: 'admin' }] }) + let raw = (await rawProcessing(processing._id)).data + expect(raw.created.name).toBe('Renamed Admin') + expect(raw.updated.name).toBe('Renamed Admin') + + await axIdentities.post('/api/identities/organization/test_org2', { name: 'Renamed Org 2' }) + raw = (await rawProcessing(processing._id)).data + expect(raw.permissions[0].target.organization.name).toBe('Renamed Org 2') + + // test_org3 is no longer a partner of the owner, the user email permission is not concerned + await axIdentities.post('/api/identities/organization/test_org1', { name: 'Test Org 1', partners: [{ id: 'test_org2', name: 'Renamed Org 2' }] }) + raw = (await rawProcessing(processing._id)).data + expect(raw.permissions.map((p: any) => p.target.type + ':' + (p.target.organization?.id ?? p.target.email))).toEqual(['partner:test_org2', 'userEmail:test_alone@test.com']) + }) + + test('should keep only the id of a deleted user and drop a deleted partner', async () => { + const processing = await seedProcessing() + + await axIdentities.delete('/api/identities/user/test_admin1') + let raw = (await rawProcessing(processing._id)).data + expect(raw.created.name).toBeUndefined() + expect(raw.created.id).toBe('test_admin1') + expect(raw.updated.name).toBeUndefined() + + await axIdentities.delete('/api/identities/organization/test_org2') + raw = (await rawProcessing(processing._id)).data + expect(raw.permissions.map((p: any) => p.target.organization?.id ?? p.target.email)).toEqual(['test_org3', 'test_alone@test.com']) + }) + + test('should delete everything a deleted organization owns, its directory included', async () => { + const processing = await seedProcessing() + const superadmin = await axiosAuth({ email: 'test_superadmin@test.com', adminMode: true }) + await superadmin.post('/api/v1/limits/organization/test_org1', { lastUpdate: new Date().toISOString(), processings_seconds: { limit: 100 } }) + fs.mkdirSync(path.join(processingsDir, processing._id), { recursive: true }) + fs.writeFileSync(path.join(processingsDir, processing._id, 'log.txt'), 'kept between runs') + + await axIdentities.delete('/api/identities/organization/test_org1') + expect((await rawProcessing(processing._id)).status).toBe(404) + expect(fs.existsSync(path.join(processingsDir, processing._id))).toBe(false) + expect((await superadmin.get('/api/v1/limits', { params: { type: 'organization', id: 'test_org1' } })).data.results).toHaveLength(0) + }) +}) From e7916b980d9eefc4300535259e8dc832a8c24a79 Mon Sep 17 00:00:00 2001 From: BatLeDev Date: Mon, 14 Sep 2026 16:43:21 +0200 Subject: [PATCH 2/8] feat(ui): label the author of a processing once the user is gone --- ui/src/components/processing/processing-activity.vue | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/src/components/processing/processing-activity.vue b/ui/src/components/processing/processing-activity.vue index 991e897d..c4e4193c 100644 --- a/ui/src/components/processing/processing-activity.vue +++ b/ui/src/components/processing/processing-activity.vue @@ -7,13 +7,13 @@ import type { Processing } from '#api/types' +const { t } = useI18n() const { dayjs } = useLocaleDayjs() const { processing, pluginTitle } = defineProps<{ @@ -47,5 +48,12 @@ const avatarUrl = computed(() => { + + en: + formerUser: Former user + fr: + formerUser: Ancien utilisateur + + From 7de4f0a3cd11c4c646aaca45646e619851fda2c0 Mon Sep 17 00:00:00 2001 From: BatLeDev Date: Mon, 14 Sep 2026 16:57:53 +0200 Subject: [PATCH 3/8] chore(dev): simple-directory notifies only this service, directly The shared identities router refuses the calls that come through the proxy, and the other services of the dev environment do not need the webhooks. --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index ad13ce63..6532823c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ services: - CONTACT=contact@test.com - ADMINS=["superadmin@test.com","test_superadmin@test.com"] - PUBLIC_URL=http://${DEV_HOST}:${NGINX_PORT1}/simple-directory + # in dev simple-directory only notifies this service, directly (the shared identities router refuses calls through the proxy) + - IDENTITIES_WEBHOOKS=[{"base":"http://localhost:${DEV_API_PORT}/api/identities","key":"secret-identities"}] - MAILDEV_ACTIVE=true - MONGO_URL=mongodb://localhost:${MONGO_PORT}/simple-directory - STORAGE_TYPE=file From 9269835c2b73158f9101de76fdaa4be41a2ffd91 Mon Sep 17 00:00:00 2001 From: BatLeDev Date: Tue, 15 Sep 2026 09:21:23 +0200 Subject: [PATCH 4/8] feat(identities): forget the name of a deleted department and label it in the UI A department missing from the complete list sent by simple-directory was deleted: its processings and runs keep the department id but lose departmentName. The UI shows them as "Former department - " (owner avatar, activity, owner filters). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hqsZKwijEBoB3m3v9srVD --- api/src/misc/routers/identities.ts | 7 ++++- .../processings/identities.api.spec.ts | 17 ++++++++++++ .../processing/processing-activity.vue | 3 ++- .../components/processing/processing-card.vue | 3 ++- ui/src/components/processings-actions.vue | 3 ++- ui/src/composables/use-display-owner.ts | 27 +++++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 ui/src/composables/use-display-owner.ts diff --git a/api/src/misc/routers/identities.ts b/api/src/misc/routers/identities.ts index ba1eee5f..a12bd304 100644 --- a/api/src/misc/routers/identities.ts +++ b/api/src/misc/routers/identities.ts @@ -62,6 +62,12 @@ export default createIdentitiesRouter( ) ) await Promise.all(departmentUpdates) + // the directory sends the complete list of departments: a department missing from it was + // deleted, its resources keep the id (still reachable by the organization admins) but not the name + await updateAllCollections( + { 'owner.type': type, 'owner.id': id, 'owner.department': { $exists: true, $nin: identity.departments.map(d => d.id) } }, + { $unset: { 'owner.departmentName': 1 } } + ) } }, @@ -91,6 +97,5 @@ export default createIdentitiesRouter( updateAllCollections({ 'updated.id': id }, { $unset: { 'updated.name': 1 } }) ]) } - // When departments are deleted, do nothing } ) diff --git a/tests/features/processings/identities.api.spec.ts b/tests/features/processings/identities.api.spec.ts index a93764b7..a782b252 100644 --- a/tests/features/processings/identities.api.spec.ts +++ b/tests/features/processings/identities.api.spec.ts @@ -45,6 +45,23 @@ test.describe('identity webhooks', () => { expect(raw.permissions.map((p: any) => p.target.type + ':' + (p.target.organization?.id ?? p.target.email))).toEqual(['partner:test_org2', 'userEmail:test_alone@test.com']) }) + test('should rename a department and forget the name of a deleted one', async () => { + const plugin = await publishFixturePlugin({ name: '@data-fair/processing-hello-world', version: '1.2.2' }) + const admin = await axiosAuth({ email: 'test_admin1@test.com', org: 'test_org1', dep: 'dep1' }) + const processing = (await admin.post('/api/v1/processings', { title: 'Department processing', plugin: plugin.pluginId })).data + expect(processing.owner.department).toBe('dep1') + + await axIdentities.post('/api/identities/organization/test_org1', { name: 'Test Org 1', departments: [{ id: 'dep1', name: 'Renamed Department' }] }) + let raw = (await rawProcessing(processing._id)).data + expect(raw.owner.departmentName).toBe('Renamed Department') + + // dep1 is missing from the complete list of departments: it was deleted, only its id remains + await axIdentities.post('/api/identities/organization/test_org1', { name: 'Test Org 1', departments: [{ id: 'dep2', name: 'Department 2' }] }) + raw = (await rawProcessing(processing._id)).data + expect(raw.owner.department).toBe('dep1') + expect(raw.owner.departmentName).toBeUndefined() + }) + test('should keep only the id of a deleted user and drop a deleted partner', async () => { const processing = await seedProcessing() diff --git a/ui/src/components/processing/processing-activity.vue b/ui/src/components/processing/processing-activity.vue index c4e4193c..ca40448e 100644 --- a/ui/src/components/processing/processing-activity.vue +++ b/ui/src/components/processing/processing-activity.vue @@ -26,6 +26,7 @@ import type { Processing } from '#api/types' const { t } = useI18n() +const { departmentLabel } = useDisplayOwner() const { dayjs } = useLocaleDayjs() const { processing, pluginTitle } = defineProps<{ @@ -36,7 +37,7 @@ const { processing, pluginTitle } = defineProps<{ const ownerName = computed(() => { if (!processing.owner) return '' const baseName = processing.owner.name || processing.owner.id - const departmentInfo = processing.owner.departmentName || processing.owner.department + const departmentInfo = departmentLabel(processing.owner.department, processing.owner.departmentName) return departmentInfo ? `${baseName} - ${departmentInfo}` : baseName diff --git a/ui/src/components/processing/processing-card.vue b/ui/src/components/processing/processing-card.vue index 2d6284bb..2c40ae98 100644 --- a/ui/src/components/processing/processing-card.vue +++ b/ui/src/components/processing/processing-card.vue @@ -18,7 +18,7 @@ @@ -177,6 +177,7 @@