Allow EditRestricted writes for museum/reference

This commit is contained in:
karilint
2026-04-08 18:28:17 +03:00
parent 52dd7d55b5
commit 1251e8bb16
14 changed files with 195 additions and 45 deletions
+15 -1
View File
@@ -65,19 +65,33 @@ describe('Creating new locality works', () => {
expect(created!.synonyms).toEqual(expect.arrayContaining(['Shuijiazui', 'Bahe']))
})
it('Creation fails without permissions', async () => {
it('Creation fails without permissions for unauthenticated users', async () => {
logout()
const resultNoPerm = await send('locality', 'PUT', {
locality: { ...newLocalityBasis },
})
expect(resultNoPerm.body).toEqual(noPermError)
expect(resultNoPerm.status).toEqual(403)
})
it('Creation succeeds for EditRestricted users within their projects', async () => {
logout()
await login('testEr', 'test')
const resultEr = await send('locality', 'PUT', {
locality: { ...newLocalityBasis },
})
expect(resultEr.status).toEqual(200)
})
it('Creation fails for EditRestricted users outside their projects', async () => {
logout()
await login('testEr', 'test')
const resultEr = await send('locality', 'PUT', {
locality: {
...newLocalityBasis,
now_plr: [{ pid: 35, rowState: 'new' }],
},
})
expect(resultEr.body).toEqual(noPermError)
expect(resultEr.status).toEqual(403)
})
+8 -8
View File
@@ -71,17 +71,17 @@ describe('Creating new museum works', () => {
expect(resultStatusNoPerm).toEqual(403)
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'New Museum 3' },
const { body: resultBodyEr, status: resultStatusEr } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'NM3' },
})
expect(resultBodyEr).toEqual(noPermError)
expect(resultStatusEr).toEqual(403)
expect(resultStatusEr).toEqual(200)
expect(typeof resultBodyEr.museum).toEqual('string')
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'New Museum 4' },
const { body: resultBodyEu, status: resultStatusEu } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'NM4' },
})
expect(resultBodyEu).toEqual(noPermError)
expect(resultStatusEu).toEqual(403)
expect(resultStatusEu).toEqual(200)
expect(typeof resultBodyEu.museum).toEqual('string')
})
})
+6 -6
View File
@@ -56,17 +56,17 @@ describe('Updating museum works', () => {
expect(resultStatusNoPerm).toEqual(403)
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send('museum/', 'PUT', {
const { body: resultBodyEr, status: resultStatusEr } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...editedMuseum, institution: 'New Museum 2' },
})
expect(resultBodyEr).toEqual(noPermError)
expect(resultStatusEr).toEqual(403)
expect(resultStatusEr).toEqual(200)
expect(typeof resultBodyEr.museum).toEqual('string')
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send('museum/', 'PUT', {
const { body: resultBodyEu, status: resultStatusEu } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...editedMuseum, institution: 'New Museum 2' },
})
expect(resultBodyEu).toEqual(noPermError)
expect(resultStatusEu).toEqual(403)
expect(resultStatusEu).toEqual(200)
expect(typeof resultBodyEu.museum).toEqual('string')
})
})
@@ -182,10 +182,10 @@ describe('Creating new reference works', () => {
expect(resultStatusNoPerm).toEqual(403)
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send('reference/', 'PUT', {
const { body: resultBodyEr, status: resultStatusEr } = await send<{ rid: number }>('reference/', 'PUT', {
reference: { ...newReferenceBasis },
})
expect(resultBodyEr).toEqual(noPermError)
expect(resultStatusEr).toEqual(403)
expect(resultStatusEr).toEqual(200)
expect(typeof resultBodyEr.rid).toEqual('number')
})
})
+5 -4
View File
@@ -15,7 +15,7 @@ describe('Creating new species works', () => {
await resetDatabase()
await login()
createdSpecies = null
})
}, resetDatabaseTimeout)
afterAll(async () => {
await pool.end()
})
@@ -83,20 +83,21 @@ describe('Creating new species works', () => {
expect(resultWithRef.status).toEqual(200)
})
it('Creation fails without permissions for non-authenticated and non-privileged users', async () => {
it('Creation fails without permissions for non-authenticated users', async () => {
logout()
const result1 = await send('species', 'PUT', {
species: { ...newSpeciesBasis, comment: 'species test' },
})
expect(result1.body).toEqual(noPermError)
expect(result1.status).toEqual(403)
})
it('Creation succeeds for EditRestricted users', async () => {
logout()
await login('testEr', 'test')
const result2 = await send('species', 'PUT', {
species: { ...newSpeciesBasis, comment: 'species test' },
})
expect(result2.body).toEqual(noPermError)
expect(result2.status).toEqual(403)
expect(result2.status).toEqual(200)
})
})
+7 -2
View File
@@ -1,13 +1,14 @@
import { Request, Router } from 'express'
import {
getAllLocalities,
canEditRestrictedWriteLocality,
getLocalityDetails,
normalizeLocalityAges,
validateEntireLocality,
} from '../services/locality'
import { fixBigInt } from '../utils/common'
import { EditDataType, EditMetaData, LocalityDetailsType, Role } from '../../../frontend/src/shared/types'
import { requireOneOf } from '../middlewares/authorizer'
import { AccessError, requireOneOf } from '../middlewares/authorizer'
import { deleteLocality, writeLocality } from '../services/write/locality'
const router = Router()
@@ -26,9 +27,13 @@ router.get('/:id', async (req, res) => {
router.put(
'/',
requireOneOf([Role.Admin, Role.EditUnrestricted]),
requireOneOf([Role.Admin, Role.EditUnrestricted, Role.EditRestricted]),
async (req: Request<object, object, { locality: EditDataType<LocalityDetailsType> & EditMetaData }>, res) => {
const { comment, references, ...editedLocality } = req.body.locality
if (req.user?.role === Role.EditRestricted) {
const hasAccess = await canEditRestrictedWriteLocality(editedLocality, req.user)
if (!hasAccess) throw new AccessError()
}
const normalizedLocality = normalizeLocalityAges(editedLocality)
const validationErrors = await validateEntireLocality({ ...normalizedLocality, references: references })
if (validationErrors.length > 0) {
+1 -1
View File
@@ -32,7 +32,7 @@ router.get('/:id', async (req, res) => {
router.put(
'/',
requireOneOf([Role.Admin]),
requireOneOf([Role.Admin, Role.EditUnrestricted, Role.EditRestricted]),
async (req: Request<object, object, { museum: EditDataType<Museum> & EditMetaData }>, res) => {
try {
const { ...editedMuseum } = req.body.museum
+1 -1
View File
@@ -100,7 +100,7 @@ router.get('/species/:id', async (req, res) => {
router.put(
'/',
requireOneOf([Role.Admin, Role.EditUnrestricted]),
requireOneOf([Role.Admin, Role.EditUnrestricted, Role.EditRestricted]),
async (req: Request<object, object, { reference: EditDataType<ReferenceDetailsType> & EditMetaData }>, res) => {
const { ...editedReference } = req.body.reference
const referenceTypes = await getReferenceTypes()
+1 -1
View File
@@ -26,7 +26,7 @@ router.get('/:id', async (req, res) => {
router.put(
'/',
requireOneOf([Role.Admin, Role.EditUnrestricted]),
requireOneOf([Role.Admin, Role.EditUnrestricted, Role.EditRestricted]),
async (req: Request<object, object, { species: SpeciesDetailsType & EditMetaData }>, res) => {
const { comment, references, ...editedSpecies } = req.body.species
const validationErrors = await validateEntireSpecies({ ...editedSpecies, references })
+31 -1
View File
@@ -47,7 +47,7 @@ export const normalizeLocalityAges = (locality: EditDataType<LocalityDetailsType
frac_min: normalizeFraction(locality.frac_min) as EditDataType<LocalityDetailsType>['frac_min'],
})
const getIdsOfUsersProjects = async (user: User) => {
export const getIdsOfUsersProjects = async (user: User) => {
const usersProjects = await nowDb.now_proj_people.findMany({
where: { initials: user.initials },
select: { pid: true },
@@ -56,6 +56,36 @@ const getIdsOfUsersProjects = async (user: User) => {
return new Set(usersProjects.map(({ pid }) => pid))
}
const getActiveProjectIds = (locality: EditDataType<LocalityDetailsType>) => {
if (!locality.now_plr) return [] as number[]
return locality.now_plr
.filter(link => link.rowState !== 'removed')
.map(link => link.pid ?? link.now_proj?.pid)
.flatMap(pid => {
if (typeof pid === 'string') {
const parsed = parseInt(pid, 10)
return Number.isFinite(parsed) ? [parsed] : []
}
if (typeof pid === 'number' && Number.isFinite(pid)) return [pid]
return []
})
}
export const canEditRestrictedWriteLocality = async (locality: EditDataType<LocalityDetailsType>, user: User) => {
const userProjectIds = await getIdsOfUsersProjects(user)
let activeProjectIds = getActiveProjectIds(locality)
if (activeProjectIds.length === 0 && locality.lid) {
const existingLocality = await getLocalityDetails(locality.lid, user)
if (existingLocality) {
activeProjectIds = existingLocality.now_plr.map(link => link.pid)
}
}
if (activeProjectIds.length === 0) return false
return activeProjectIds.every(pid => userProjectIds.has(pid))
}
type LocalityPreFilter = {
lid: number
loc_name: string
+110 -3
View File
@@ -3,7 +3,7 @@ import { logger } from './logger'
import { PrismaClient as NowClient } from '../../prisma/generated/now_test_client'
import { PrismaClient as LogClient } from '../../prisma/generated/now_log_test_client/default'
import mariadb from 'mariadb'
import { MARIADB_HOST, MARIADB_PASSWORD, DB_CONNECTION_LIMIT, MARIADB_USER, RUNNING_ENV } from './config'
import { MARIADB_HOST, MARIADB_PASSWORD, DB_CONNECTION_LIMIT, MARIADB_USER, RUNNING_ENV, LOG_DB_NAME } from './config'
import { readFile } from 'fs/promises'
import { PathLike } from 'fs'
@@ -16,8 +16,8 @@ type LogRecord = Record<string, unknown> & {
}
type LogModel = {
findMany: (...args: unknown[]) => Promise<LogRecord[]>
findFirst: (...args: unknown[]) => Promise<LogRecord | null>
findMany: (args?: { where?: Record<string, unknown> }) => Promise<LogRecord[]>
findFirst: (args?: { where?: Record<string, unknown> }) => Promise<LogRecord | null>
fields: Record<string, unknown>
}
@@ -26,6 +26,113 @@ type LogPrismaClient = LogClient & { log: LogModel }
export const logDb = new LogClient() as unknown as LogPrismaClient
export const nowDb = new NowClient()
const fallbackLogFields = {
log_id: true,
event_time: true,
user_name: true,
server_name: true,
table_name: true,
pk_data: true,
column_name: true,
log_action: true,
old_data: true,
new_data: true,
luid: true,
suid: true,
tuid: true,
buid: true,
} as const
const buildLogWhere = (where?: Record<string, unknown>) => {
if (!where) return { sql: '', params: [] as unknown[] }
const clauses: string[] = []
const params: unknown[] = []
const pushInClause = (field: string, values: unknown[] | undefined) => {
if (!values || values.length === 0) {
clauses.push('1=0')
return
}
const placeholders = values.map(() => '?').join(', ')
clauses.push(`${field} IN (${placeholders})`)
params.push(...values)
}
if (typeof where.table_name === 'string') {
clauses.push('table_name = ?')
params.push(where.table_name)
}
const pkData = where.pk_data as { contains?: string } | undefined
if (pkData?.contains) {
clauses.push('pk_data LIKE ?')
params.push(`%${pkData.contains}%`)
}
if (Object.prototype.hasOwnProperty.call(where, 'luid')) {
const luid = where.luid as { in?: number[] } | undefined
pushInClause('luid', luid?.in)
}
if (Object.prototype.hasOwnProperty.call(where, 'suid')) {
const suid = where.suid as { in?: number[] } | undefined
pushInClause('suid', suid?.in)
}
if (Object.prototype.hasOwnProperty.call(where, 'tuid')) {
const tuid = where.tuid as { in?: number[] } | undefined
pushInClause('tuid', tuid?.in)
}
if (Object.prototype.hasOwnProperty.call(where, 'buid')) {
const buid = where.buid as { in?: number[] } | undefined
pushInClause('buid', buid?.in)
}
if (clauses.length === 0) return { sql: '', params }
return { sql: ` WHERE ${clauses.join(' AND ')}`, params }
}
const runLogQuery = async (sql: string, params: unknown[]): Promise<LogRecord[]> => {
const result = await (pool as { query: (sql: string, params?: unknown[]) => Promise<unknown> }).query(sql, params)
if (!Array.isArray(result)) return []
return result.filter((row): row is LogRecord => typeof row === 'object' && row !== null)
}
const getExistingLogModel = () => {
const candidate = (logDb as { log?: LogModel }).log
if (!candidate) return null
if (typeof candidate.findMany !== 'function') return null
if (!candidate.fields) return null
return candidate
}
const createFallbackLogModel = (): LogModel => {
const findMany = async ({ where }: { where?: Record<string, unknown> } = {}) => {
const { sql, params } = buildLogWhere(where)
return runLogQuery(`SELECT * FROM ${LOG_DB_NAME}.log${sql}`, params)
}
const findFirst = async ({ where }: { where?: Record<string, unknown> } = {}) => {
const rows = await findMany({ where })
return rows[0] ?? null
}
return {
fields: fallbackLogFields,
findMany,
findFirst,
}
}
const ensureLogClient = () => {
if (getExistingLogModel()) return
;(logDb as { log: LogModel }).log = createFallbackLogModel()
}
ensureLogClient()
export const getCrossSearchFields = () => {
const nowLsKeys = Object.keys(
(nowDb['now_ls' as keyof object] as unknown as Record<string, { fields: object }>).fields as never
+3 -10
View File
@@ -20,7 +20,7 @@ In the new UI platform, there are 4 user roles:
Users who have not logged in have the **ReadOnly** role.
The Project, ProjectPrivate, and NowOffice roles exist in the old code but are currently not used. They don't exist at all in the frontend, and in the backend they are only used when assigning roles to test users.
The **EditRestricted** role is also only used in the frontend to show/hide editing buttons, the backend does not use the role in any way.
The **EditRestricted** role is enforced in the backend for specific write operations (for example, locality writes are limited to the user's projects).
## Locality
@@ -29,10 +29,7 @@ The **EditRestricted** role is also only used in the frontend to show/hide editi
- Creating or updating a locality requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
- Deleting a locality requires the **Admin** or **EditUnrestricted** roles.
NOTE: Users with the **EditRestricted** role should only be able to create or update localities which are in the same project as the user. Otherwise, they have reading rights only.
Current behaviour:
- Users with the **EditRestricted** role can access the edit view in **their own** localities, but sending the PUT request to the backend fails because the role doesn't have permissions.
NOTE: Users with the **EditRestricted** role can only create or update localities that belong to the same project as the user. Otherwise, they have reading rights only.
## Species
@@ -42,10 +39,6 @@ Current behaviour:
- Deleting a species requires the **Admin** or **EditUnrestricted** roles.
- Deleting a species synonym requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
Current behaviour:
- Users with the **EditRestricted** role can access the editing/creating new species view from the frontend, but sending the PUT request to the backend fails because the role doesn't have permissions.
## References
- Any user can get a list of References.
@@ -108,7 +101,7 @@ Current behaviour:
| ---------------- | -------- | ------- | ---------- | ---------- | ----------- | ------- | ------- | -------- | ------- | ---------------------- | ------------- |
| Admin | ALL | ALL | ALL | ALL | ALL | ALL | ALL | ALL | ALL | ALL | C |
| EditUnRestricted | ALL | ALL | ALL | ALL | ALL | X | X\*\*\* | R\*\* | ALL | ALL | C |
| EditRestricted | CRU | CRU | CRU | R | R | X | X\*\*\* | R\*\* | R | R | C |
| EditRestricted | CRU | CRU | CRU | R | R | X | X\*\*\* | R\*\* | CRU | R | C |
| ReadOnly | R | R | R | R | R | X | X\*\*\* | R\*\* | R | R | C |
C = Create
+3 -3
View File
@@ -124,9 +124,9 @@ export const museumPage = (
idFieldName="museum"
createTitle={(museum: Museum) => `${museum.institution}`}
createSubtitle={(museum: Museum) => `${museum.city ? `${museum.city}, ` : ''}${museum.country}`}
getEditRights={(user: UserState, id: string | number) => {
getEditRights={(user: UserState) => {
if ([Role.Admin, Role.EditUnrestricted].includes(user.role)) return fullRights
if (user.role === Role.EditRestricted && user.localities.includes(id as number)) return limitedRights
if (user.role === Role.EditRestricted) return limitedRights
return noRights
}}
/>
@@ -144,7 +144,7 @@ export const referencePage = (
createSubtitle={createReferenceSubtitle}
getEditRights={(user: UserState) => {
if ([Role.Admin, Role.EditUnrestricted].includes(user.role)) return fullRights
if (user.role === Role.EditRestricted) return { new: true }
if (user.role === Role.EditRestricted) return limitedRights
return noRights
}}
/>
+1 -1
View File
@@ -946,6 +946,7 @@ LOCK TABLES `now_proj_people` WRITE;
/*!40000 ALTER TABLE `now_proj_people` DISABLE KEYS */;
INSERT INTO `now_proj_people` VALUES
(3,'ER'),
(3,'TEST-ER'),
(14,'ER'),
(35,'TEST-PL'),
(36,'TEST-PL');
@@ -2241,4 +2242,3 @@ INSERT INTO `ref_ref_type` VALUES
(14,'Undefined');
/*!40000 ALTER TABLE `ref_ref_type` ENABLE KEYS */;
UNLOCK TABLES;