mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Allow EditRestricted writes for museum/reference
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user