Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
371d6bd
feat: add access-ref type
BatLeDev Mar 13, 2026
1281153
chore: add '-' option to department access ref type
BatLeDev Mar 13, 2026
2383618
feat: implement access reference matching and MongoDB filter for perm…
BatLeDev Mar 13, 2026
8d85f1c
feat: add migration script to initialize public and permissions field…
BatLeDev Mar 13, 2026
7eea77b
feat: add permissions management into api
BatLeDev Mar 13, 2026
0a7cdce
feat: add permissions settings in UI
BatLeDev Mar 13, 2026
57ea955
feat: add pages permissions on nuxt
BatLeDev Mar 13, 2026
0954b81
chore: use ts
BatLeDev Mar 16, 2026
b1e70a2
refactor: remove use-accessible-links and clean up portal routes
BatLeDev Mar 17, 2026
44943d2
fix: uniformize operation filter and fix type in permissions utils
BatLeDev Mar 17, 2026
6699fd3
chore: use access-ref from lib
BatLeDev Mar 17, 2026
488c519
Merge remote-tracking branch 'origin/master' into refactor-permissions
BatLeDev Mar 18, 2026
d36f705
fix: force process exit after nuxt build to work around esbuild zombi…
BatLeDev Mar 18, 2026
471a9de
fix: gracefully close elasticsearch and mongodb connections on shutdown
BatLeDev Mar 18, 2026
6fd699f
fix: use 204 No Content instead of 201 for non-creation endpoints
BatLeDev Mar 18, 2026
ff0d6ba
fix: add types-portals to Docker manager image to resolve runtime import
BatLeDev Mar 18, 2026
2737dde
fix: missing tough-cookie for search-page and axios-with-cookies
BatLeDev Mar 18, 2026
3d1b2f0
fix: add http:// protocol prefix to default elasticsearch node URL
BatLeDev Mar 18, 2026
6d6f2c1
feat: add visibility on backoffice page card
BatLeDev Mar 19, 2026
ace38af
fix: remove acceptDepAsRoot
BatLeDev Mar 19, 2026
7d3395c
fix: admins can't see portals pages
BatLeDev Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ COPY --from=package-strip /app/package-lock.json package-lock.json
ADD ui/package.json ui/package.json
ADD api/package.json api/package.json
ADD portal/package.json portal/package.json
ADD types-portals/package.json types-portals/package.json
ADD patches patches
# full deps install used for types and ui building
# also used to fill the npm cache for faster install of api deps
Expand All @@ -45,6 +46,7 @@ FROM installer AS types
ADD api/config api/config
ADD api/types api/types
ADD api/doc api/doc
ADD types-portals types-portals
RUN npm run build-types

# =============================
Expand Down Expand Up @@ -124,6 +126,7 @@ ADD /api api
COPY --from=types /app/api/types api/types
COPY --from=types /app/api/doc api/doc
COPY --from=types /app/api/config api/config
COPY --from=types /app/types-portals types-portals
COPY --from=api-installer /app/shared/node_modules shared/node_modules
COPY --from=api-installer /app/api/node_modules api/node_modules
COPY --from=ui /app/ui/dist ui/dist
Expand Down
2 changes: 1 addition & 1 deletion api/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default {
ingressControllers: [],
upgradeRoot: '/app/',
elasticsearch: {
nodes: ['localhost:9200'],
nodes: ['http://localhost:9200'],
// auth: {},
// ca: '', // the central authority for the ES cluster certificates
},
Expand Down
13 changes: 13 additions & 0 deletions api/doc/pages/get-page-res/schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default {
$id: 'https://github.com/data-fair/portals/pages/get-page-res',
'x-exports': ['types'],
title: 'PageEnriched',
type: 'object',
allOf: [{ $ref: 'https://github.com/data-fair/portals/page' }],
properties: {
userPermissions: {
type: 'array',
items: { type: 'string', enum: ['read', 'write'] }
}
}
}
2 changes: 1 addition & 1 deletion api/doc/pages/get-res/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default {
},
results: {
type: 'array',
items: { $ref: 'https://github.com/data-fair/portals/page' }
items: { $ref: 'https://github.com/data-fair/portals/pages/get-page-res' }
}
},
$defs: {
Expand Down
1 change: 1 addition & 0 deletions api/doc/pages/patch-permissions-req/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './.type/index.js'
14 changes: 14 additions & 0 deletions api/doc/pages/patch-permissions-req/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import jsonSchema from '@data-fair/lib-utils/json-schema.js'
import pageSchema from '#types/page/schema.js'

export default {
...jsonSchema(pageSchema)
.pickProperties(['public', 'permissions'])
.removeRequired()
.schema,
$id: 'https://github.com/data-fair/portals/pages/patch-permissions-req',
title: 'Patch page permissions req body',
'x-exports': ['validate', 'types', 'vjsf'],
'x-vjsf': { compName: 'page-permissions' },
layout: { title: null }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import pageSchema from '#types/page/schema.js'

export default {
...jsonSchema(pageSchema)
.pickProperties(['draftConfig', 'portals', 'requestedPortals', 'owner', 'isReference', 'title'])
.pickProperties(['draftConfig', 'portals', 'requestedPortals', 'owner', 'public', 'permissions', 'isReference', 'title'])
.removeRequired()
.schema,
$id: 'https://github.com/data-fair/portals/pages/patch-req-body',
Expand Down
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"resolve-path": "^1.4.0",
"sharp": "^0.34.3",
"slugify": "^1.6.6",
"tinycolor2": "^1.6.0"
"tinycolor2": "^1.6.0",
"tough-cookie": "^5.1.2"
}
}
104 changes: 104 additions & 0 deletions api/src/pages/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Page, PageElement } from '#types/page/index.ts'
import { getAccountRole, type SessionStateAuthenticated } from '@data-fair/lib-express'
import { matchAccessRef, mongoFilterAccessRef } from '@data-fair/lib-common-types/access-ref/index.js'

export const traversePageElements = async (
pageElements: PageElement[] | undefined,
callback: (pageElement: PageElement) => Promise<void> | void
) => {
if (!pageElements) return
for (const element of pageElements) {
await callback(element)
if (element.type === 'card') await traversePageElements(element.children, callback)
if (element.type === 'banner') await traversePageElements(element.children, callback)
if (element.type === 'responsive-grid') await traversePageElements(element.children, callback)
if (element.type === 'datasets-catalog') await traversePageElements(element.advancedFilters, callback)
if (element.type === 'applications-catalog') await traversePageElements(element.advancedFilters, callback)
if (element.type === 'reuses-catalog') await traversePageElements(element.advancedFilters, callback)

if (element.type === 'two-columns') {
await traversePageElements(element.children, callback)
await traversePageElements(element.children2, callback)
}

if (element.type === 'tabs') {
for (const tab of element.tabs) {
await traversePageElements(tab.children, callback)
}
}

if (element.type === 'expansion-panels') {
for (const tab of element.panels) {
await traversePageElements(tab.children, callback)
}
}
}
}

export const canReadPage = (session: SessionStateAuthenticated, page: Page): boolean => {
const accountRole = getAccountRole(session, page.owner)
if (accountRole === 'admin') return true
if (page.public) return true
const permissions = page.permissions ?? []
return permissions.some(perm =>
perm.operation.includes('read') && matchAccessRef(session, perm.access)
)
}

export const canWritePage = (session: SessionStateAuthenticated, page: Page): boolean => {
const accountRole = getAccountRole(session, page.owner)
if (accountRole === 'admin') return true
const permissions = page.permissions ?? []
return permissions.some(perm =>
perm.operation.includes('write') && matchAccessRef(session, perm.access)
)
}

export const getUserPermissions = (session: SessionStateAuthenticated, page: Page): ('read' | 'write')[] => {
const accountRole = getAccountRole(session, page.owner)
if (accountRole === 'admin') return ['read', 'write']
const ops = new Set<'read' | 'write'>()
if (page.public) ops.add('read')
for (const perm of page.permissions ?? []) {
if (matchAccessRef(session, perm.access)) {
for (const op of perm.operation) ops.add(op as 'read' | 'write')
}
}
return [...ops]
}

export const buildPageAccessFilter = (session: SessionStateAuthenticated): Record<string, any> => {
return {
$or: [
{ public: true },
{
permissions: {
$elemMatch: {
...mongoFilterAccessRef(session),
operation: { $in: ['read'] }
}
}
}
]
}
}

export const buildDefaultPermissions = (
session: SessionStateAuthenticated,
owner: SessionStateAuthenticated['account']
): Page['permissions'] => {
const ownerRole = getAccountRole(session, owner)
if (ownerRole === 'contrib' && owner.type === 'organization') {
return [{
access: {
mode: 'internal',
type: 'organization',
id: owner.id,
department: owner.department || '*',
roles: ['contrib']
},
operation: ['read', 'write']
}]
}
return []
}
103 changes: 86 additions & 17 deletions api/src/pages/router.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Page } from '#types/page/index.ts'
import type { Portal } from '#types/portal/index.ts'
import { randomUUID } from 'node:crypto'
import { Router } from 'express'
import mongo from '#mongo'
import findUtils from '../utils/find.ts'
import * as postReqBody from '#doc/pages/post-req-body/index.ts'
import * as patchReqBody from '#doc/pages/patch-req-body/index.ts'
import { httpError, reqSessionAuthenticated, assertAccountRole, assertAdminMode } from '@data-fair/lib-express/index.js'
import { createPage, validatePageDraft, cancelPageDraft, getPageAsContrib, patchPage, deletePage, generateUniqueSlug, duplicatePageElements, sendPageEvent } from './service.ts'
import { httpError, reqSessionAuthenticated, assertAccountRole, assertAdminMode, getAccountRole } from '@data-fair/lib-express/index.js'
import { createPage, validatePageDraft, cancelPageDraft, getPage, assertPageWrite, patchPage, deletePage, generateUniqueSlug, duplicatePageElements, sendPageEvent } from './service.ts'
import { buildDefaultPermissions, buildPageAccessFilter, getUserPermissions } from './operations.ts'
import { reindexPage } from '../search-pages/service.ts'
import { pageFacets } from './aggregations.ts'

Expand All @@ -15,7 +17,6 @@ export default router

router.get('', async (req, res, next) => {
const session = reqSessionAuthenticated(req)
assertAccountRole(session, session.account, 'admin')

const params = req.query as Record<string, string>
const sort = findUtils.sort(params.sort || 'createdAt:-1')
Expand All @@ -27,19 +28,36 @@ router.get('', async (req, res, next) => {
if (params.groupId === 'default') filters['config.genericMetadata.group._id'] = { $exists: false }

// If isReference=true, we get all references pages, without owner filter
const query = params.isReference === 'true' ? { isReference: true } : findUtils.filterPermissions(params, session)
let query: Record<string, any>
if (params.isReference === 'true') {
query = { isReference: true }
} else {
query = findUtils.filterPermissions(params, session)
// Non-admins: further filter by public or explicit read permission
const accountRole = getAccountRole(session, session.account)
if (accountRole !== 'admin') {
Object.assign(query, buildPageAccessFilter(session))
}
}

const queryWithFilters = Object.assign(filters, query)

// Filter by owner (if showAll)
const showAll = params.showAll === 'true'
if (showAll && params.owners) {
queryWithFilters.$or = params.owners.split(',').map(owner => {
const ownersOr = params.owners.split(',').map(owner => {
const [type, id, department] = owner.split(':')
if (!type || !id) throw httpError(400, 'Invalid owner format')
const filter: any = { 'owner.type': type, 'owner.id': id }
if (department) filter['owner.department'] = department
return filter
})
if (queryWithFilters.$or) {
queryWithFilters.$and = [{ $or: queryWithFilters.$or }, { $or: ownersOr }]
delete queryWithFilters.$or
} else {
queryWithFilters.$or = ownersOr
}
}

const [count, results, facets] = await Promise.all([
Expand All @@ -48,7 +66,14 @@ router.get('', async (req, res, next) => {
mongo.pages.aggregate(pageFacets(query, showAll)).toArray()
])

res.json({ results, count, facets: facets[0] })
const enrichedResults = results.map(page => {
const userPermissions = getUserPermissions(session, page as Page)
const accountRole = getAccountRole(session, (page as Page).owner)
if (accountRole !== 'admin') delete (page as any).permissions
return { ...page, userPermissions }
})

res.json({ results: enrichedResults, count, facets: facets[0] })
})

router.post('', async (req, res, next) => {
Expand Down Expand Up @@ -80,6 +105,9 @@ router.post('', async (req, res, next) => {
config.elements = await duplicatePageElements(session, body.sourcePageId, pageId, owner)
}

// Allow admins and contributors to create pages
assertAccountRole(session, owner, ['admin', 'contrib'])

const page: Page = {
_id: pageId,
title: body.title || body.config.title,
Expand All @@ -90,9 +118,10 @@ router.post('', async (req, res, next) => {
config,
draftConfig: config,
portals: body.portals || [],
requestedPortals: []
requestedPortals: [],
public: false,
permissions: buildDefaultPermissions(session, owner)
}
assertAccountRole(session, page.owner, 'admin')

const creationDetails = await createPage(page, body.sourcePageId)
sendPageEvent(page, 'a été créée', 'create', session, creationDetails)
Expand All @@ -105,15 +134,51 @@ router.post('', async (req, res, next) => {
})

router.get('/:id', async (req, res, next) => {
res.send(await getPageAsContrib(reqSessionAuthenticated(req), req.params.id))
res.send(await getPage(reqSessionAuthenticated(req), req.params.id))
})

router.patch('/:id', async (req, res, next) => {
const session = reqSessionAuthenticated(req)
const page = await getPageAsContrib(session, req.params.id)
// Fetch full page (with permissions) for access checks
const page = await mongo.pages.findOne({ _id: req.params.id })
if (!page) throw httpError(404, `page "${req.params.id}" not found`)
assertPageWrite(session, page)

const body = patchReqBody.returnValid(req.body, { name: 'body' })
if (body.isReference !== undefined) assertAdminMode(session)
if (body.portals) assertAccountRole(session, page.owner, 'admin')

const ownerRole = getAccountRole(session, page.owner)

// Restrict admin-only fields: owner, permissions, public
if (body.owner !== undefined || body.permissions !== undefined || body.public !== undefined) {
if (ownerRole !== 'admin') {
throw httpError(403, 'only page owner admins can modify owner, permissions and public fields')
}
}

// Handle portals/requestedPortals changes
if (body.portals !== undefined || body.requestedPortals !== undefined) {
if (ownerRole !== 'admin') {
// Only contributors of the page owner can manage portals (non-admins)
if (ownerRole !== 'contrib') {
throw httpError(403, 'only admins and contributors of the page owner can manage portal publication')
}
// Contributors: direct publish (portals) only allowed for staging portals
if (body.portals !== undefined) {
const addedPortalIds = (body.portals as string[]).filter(id => !page.portals.includes(id))
const removedPortalIds = page.portals.filter(id => !(body.portals as string[]).includes(id))
const changedPortalIds = [...new Set([...addedPortalIds, ...removedPortalIds])]
if (changedPortalIds.length > 0) {
const changedPortals = await mongo.portals.find({ _id: { $in: changedPortalIds } }).project({ staging: 1 }).toArray() as Pick<Portal, '_id' | 'staging'>[]
const hasNonStaging = changedPortals.some(p => !p.staging)
if (hasNonStaging) {
throw httpError(403, 'contributors can only directly publish to staging portals, use requestedPortals for non-staging')
}
}
}
}
}

const updatedPage = await patchPage(page, body, session)

for (const portalId of updatedPage.portals) {
Expand All @@ -127,22 +192,26 @@ router.delete('/:id', async (req, res, next) => {
const session = reqSessionAuthenticated(req)
const page = await mongo.pages.findOne({ _id: req.params.id })
if (!page) throw httpError(404, `page "${req.params.id}" not found`)
assertAccountRole(session, page.owner, 'admin')
assertPageWrite(session, page)
await deletePage(page)
sendPageEvent(page, 'a été supprimée', 'delete', session)
res.status(201).send()
res.status(204).send()
})

router.post('/:id/draft', async (req, res, next) => {
const session = reqSessionAuthenticated(req)
const page = await getPageAsContrib(session, req.params.id)
const page = await mongo.pages.findOne({ _id: req.params.id })
if (!page) throw httpError(404, `page "${req.params.id}" not found`)
assertPageWrite(session, page)
await validatePageDraft(page, session)
res.status(201).send()
res.status(204).send()
})

router.delete('/:id/draft', async (req, res, next) => {
const session = reqSessionAuthenticated(req)
const page = await getPageAsContrib(session, req.params.id)
const page = await mongo.pages.findOne({ _id: req.params.id })
if (!page) throw httpError(404, `page "${req.params.id}" not found`)
assertPageWrite(session, page)
await cancelPageDraft(page, session)
res.status(201).send()
res.status(204).send()
})
Loading
Loading