From 8d74c22a34c0de27cc7566c60d6fe594af1cc5c2 Mon Sep 17 00:00:00 2001 From: Moses Ingersoll <258583966+burning-bush-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:05:41 +0100 Subject: [PATCH 01/48] authz: port the v2 permission model to v3 v3 didn't have permission enforcement yet -- this adds it. Hapi onPreHandler plugin reads route metadata and runs checks before handlers execute, rather than scattering permission calls inside each one. authz.js is the engine (checkPermission, group tree walks, delegation lookups). authz-plugin.js wires it into Hapi's request lifecycle. All routes annotated with what they need. Delegation routes now cap submitted permissions by the caller's own permissions at write time, which matches how v2 does it. Unit tests for the Authz class and integration tests via server.inject() included. v2 xt permission tests (14_permissions, 20_permission) should still pass -- 4892/4892 last run. --- lib/authz-plugin.js | 263 ++++++++ lib/authz.js | 511 ++++++++++++++++ lib/authz.test.js | 583 ++++++++++++++++++ lib/delegation.js | 218 +++++++ lib/group/store/mysql.js | 49 +- lib/group/test/index.js | 36 ++ lib/permission/store/mysql.js | 129 ++-- lib/permission/test/index.js | 13 + lib/session/store/mysql.js | 4 +- lib/session/test/index.js | 11 + lib/user/store/mysql.js | 8 +- lib/user/test/index.js | 18 + lib/zone/store/mysql.js | 37 +- lib/zone_record/store/mysql.js | 32 +- lib/zone_record/test/index.js | 28 + routes/authz.test.js | 1040 ++++++++++++++++++++++++++++++++ routes/delegation.js | 248 ++++++++ routes/group.js | 92 ++- routes/group.test.js | 5 + routes/index.js | 5 + routes/nameserver.js | 24 +- routes/nameserver.test.js | 2 + routes/permission.js | 70 +++ routes/permission.test.js | 67 +- routes/session.js | 21 + routes/session.test.js | 9 +- routes/test/permissions.js | 38 ++ routes/user.js | 150 ++++- routes/user.test.js | 2 + routes/zone.js | 46 +- routes/zone.test.js | 6 + routes/zone_record.js | 32 +- routes/zone_record.test.js | 2 + 33 files changed, 3653 insertions(+), 146 deletions(-) create mode 100644 lib/authz-plugin.js create mode 100644 lib/authz.js create mode 100644 lib/authz.test.js create mode 100644 lib/delegation.js create mode 100644 routes/authz.test.js create mode 100644 routes/delegation.js create mode 100644 routes/test/permissions.js diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js new file mode 100644 index 0000000..872888e --- /dev/null +++ b/lib/authz-plugin.js @@ -0,0 +1,263 @@ +import Authz from './authz.js' +import Mysql from './mysql.js' + +const TYPE_TO_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', + NAMESERVER: 'nameserver', + GROUP: 'group', +} + +const DELEGABLE_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', +} + +const authzPlugin = { + name: 'nt-authz', + register(server) { + server.ext('onPreHandler', async (request, h) => { + const isLogin = request.method === 'post' && request.route.path === '/session' + if (request.auth.isAuthenticated && !isLogin) { + const credentials = await Authz.getCurrentCredentials(request.auth.credentials) + if (!credentials) { + return h.response({ + error_code: 401, + error_msg: 'Session is no longer valid', + }).code(401).takeover() + } + request.auth.credentials = credentials + } + + const permCfg = request.route.settings.app?.permission + if (!permCfg) return h.continue + + if (!request.auth.isAuthenticated) return h.continue + + let { resource, action } = permCfg + const credentials = request.auth.credentials + + if (resource === 'permission') { + const result = action === 'create' + ? await Authz.checkPermissionTarget(credentials, request.payload) + : await Authz.checkPermissionRecord( + credentials, action, Number(resolveId(request, permCfg.idFrom)), + ) + return respond(result, h) + } + + if (action === 'readDelegation') { + const type = request.query?.type + const delegatedResource = TYPE_TO_RESOURCE[type] + if (!delegatedResource) { + return respond({ allowed: false, code: 404, msg: `Unknown delegation type` }, h) + } + if (request.query.oid !== undefined) { + const object = await Authz.checkPermission( + credentials, delegatedResource, 'read', Number(request.query.oid), + ) + if (!object.allowed) return respond(object, h) + } + if (request.query.gid !== undefined) { + const group = await Authz.checkPermission( + credentials, 'group', 'read', Number(request.query.gid), + ) + if (!group.allowed) return respond(group, h) + } + if (request.query.oid === undefined && request.query.gid === undefined) { + return respond({ allowed: false, code: 404, msg: `A delegation scope is required` }, h) + } + return h.continue + } + + let objectId + if (permCfg.idFrom) { + objectId = resolveId(request, permCfg.idFrom) + if (objectId !== undefined) objectId = Number(objectId) + } + + if (permCfg.targetGroupFrom) { + const targetGid = resolveId(request, permCfg.targetGroupFrom) + if (targetGid !== undefined) { + if ( + resource === 'group' + && action === 'write' + && ( + Number(targetGid) === objectId + || await Authz.isInGroupTree(objectId, Number(targetGid)) + ) + ) { + return respond({ allowed: false, code: 404, msg: `A group cannot contain itself` }, h) + } + if (!await Authz.isActiveGroup(Number(targetGid))) { + return respond({ allowed: false, code: 404, msg: `Target group is deleted` }, h) + } + const target = await Authz.checkPermission( + credentials, 'group', 'read', Number(targetGid), + ) + if (!target.allowed) return respond(target, h) + } + } + + if ( + permCfg.targetCreateResource + && request.payload?.zid !== undefined + && await changesTarget(request, resource, action, objectId) + ) { + const target = await resolveTargetGroup(request, permCfg.targetCreateResource) + const targetResult = await Authz.checkPermission( + credentials, + permCfg.targetCreateResource, + 'create', + undefined, + { targetGroupId: target?.gid, targetZoneId: target?.zid }, + ) + if (!targetResult.allowed) return respond(targetResult, h) + } + + if (action === 'read' && objectId === undefined) { + const list = permCfg.list + if (!list) return h.continue + resource = list.resource + objectId = resolveId(request, list.idFrom) + if (objectId === undefined && list.defaultToGroup) { + objectId = credentials.group.id + } + if (objectId === undefined) { + return respond({ + allowed: false, + code: 404, + msg: `A scoped collection id is required`, + }, h) + } + objectId = Number(objectId) + } + + if (action.endsWith('Delegation') || action === 'delegate') { + const type = request.payload?.type ?? request.query?.type + // only zones and zone records are delegable; nothing caps the granted + // permissions for the other nt_delegate types, so refuse them outright + if (!DELEGABLE_RESOURCE[type]) { + return respond({ + allowed: false, + code: 404, + msg: `${type} objects cannot be delegated`, + }, h) + } + resource = DELEGABLE_RESOURCE[type] + + const targetGid = request.payload?.gid ?? request.query?.gid + if (targetGid !== undefined) { + if (!await Authz.isActiveGroup(Number(targetGid))) { + return respond({ allowed: false, code: 404, msg: `Delegation target group is deleted` }, h) + } + const target = await Authz.checkPermission( + credentials, 'group', 'read', Number(targetGid), + ) + if (!target.allowed) return respond(target, h) + if (request.method === 'post' && Number(targetGid) === credentials.group.id) { + return respond({ + allowed: false, + code: 404, + msg: `Cannot delegate to your own group`, + }, h) + } + } + } + + let opts + if (action === 'create') { + if (request.payload?.id !== undefined) { + const existingGid = await Authz.getObjectGroupId(resource, request.payload.id) + if (existingGid !== null) { + return respond({ allowed: false, code: 404, msg: `That ${resource} id already exists` }, h) + } + } + const target = await resolveTargetGroup( + request, resource, + ) + opts = { + targetGroupId: target?.gid, + targetZoneId: target?.zid, + } + } + + const result = await Authz.checkPermission( + credentials, resource, action, objectId, opts, + ) + if (!result.allowed) return respond(result, h) + + // Only a PUT that actually flips the deleted flag is a delete; a client + // that echoes the object back unchanged needs no delete permission. + if (action === 'write' && request.payload?.deleted !== undefined) { + const wasDeleted = !await Authz.isActiveObject(resource, objectId) + if (Boolean(request.payload.deleted) !== wasDeleted) { + const deleteResult = await Authz.checkPermission( + credentials, resource, 'delete', objectId, + ) + return respond(deleteResult, h) + } + } + + return respond(result, h) + }) + }, +} + +function respond(result, h) { + if (result.allowed) return h.continue + return h.response({ + error_code: result.code, + error_msg: result.msg, + }).code(403).takeover() +} + +function resolveId(request, idFrom) { + const [source, key] = idFrom.split('.') + if (source === 'params') return request.params[key] + if (source === 'payload') return request.payload?.[key] + if (source === 'query') return request.query?.[key] +} + +// The group a create lands in must be read from the same payload key the store +// persists, or authz and the store can be pointed at different groups. +const CREATE_GROUP_KEY = { + group: 'parent_gid', + nameserver: 'gid', + user: 'gid', + zone: 'gid', +} + +async function resolveTargetGroup(request, resource) { + if (resource === 'zonerecord') { + const zid = request.payload?.zid ?? request.payload?.nt_zone_id + if (zid) { + const rows = await Mysql.execute( + 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ? AND deleted = 0', + [zid], + ) + if (rows.length > 0) { + return { gid: rows[0].nt_group_id, zid: Number(zid) } + } + } + return null + } + + const key = CREATE_GROUP_KEY[resource] + if (!key) return null + const gid = request.payload?.[key] + if (gid) return { gid: Number(gid) } + + return null +} + +async function changesTarget(request, resource, action, objectId) { + if (resource !== 'zonerecord' || action !== 'write') return true + const rows = await Mysql.execute( + 'SELECT nt_zone_id AS zid FROM nt_zone_record WHERE nt_zone_record_id = ?', + [objectId], + ) + return rows.length === 0 || rows[0].zid !== Number(request.payload.zid) +} + +export default authzPlugin diff --git a/lib/authz.js b/lib/authz.js new file mode 100644 index 0000000..98aad4f --- /dev/null +++ b/lib/authz.js @@ -0,0 +1,511 @@ +import Mysql from './mysql.js' +import Permission from './permission/index.js' + +const RESOURCE_QUERIES = { + zone: 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?', + zonerecord: `SELECT z.nt_group_id FROM nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE r.nt_zone_record_id = ?`, + user: 'SELECT nt_group_id FROM nt_user WHERE nt_user_id = ?', + group: 'SELECT parent_group_id AS nt_group_id FROM nt_group WHERE nt_group_id = ?', + nameserver: 'SELECT nt_group_id FROM nt_nameserver WHERE nt_nameserver_id = ?', +} + +const DELEGATE_TYPE = { + zone: 'ZONE', + zonerecord: 'ZONERECORD', + nameserver: 'NAMESERVER', + group: 'GROUP', +} + +const PERM_FIELDS = [ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', +] + +const CREATE_REQUIRES_GROUP = new Set([ + 'group', 'nameserver', 'user', 'zone', 'zonerecord', +]) + +const ACTION_PERMISSION = { + editDelegation: 'delegate', + deleteDelegation: 'delegate', +} + +const SESSION_MAX_AGE_SEC = 14400 + +class Authz { + async checkPermission(credentials, resource, action, objectId, opts) { + const perm = await Permission.getEffective(credentials.user.id) + if (!perm) return deny(`No permissions found`) + + if (action === 'create') { + if (perm[resource]?.create !== true) { + return deny(`Not allowed to create new ${resource}`) + } + const targetGid = opts?.targetGroupId + if (targetGid === undefined || targetGid === null) { + if (CREATE_REQUIRES_GROUP.has(resource)) { + return deny(`No target group found for new ${resource}`) + } + } else { + if (!await this.isActiveGroup(targetGid)) { + return deny(`No active target group found for new ${resource}`) + } + const inTree = await this.isInGroupTree( + credentials.group.id, targetGid, + ) + if (!inTree) { + if (resource === 'zonerecord' && opts?.targetZoneId) { + const delegation = await this.getDelegateAccess( + credentials.group.id, opts.targetZoneId, 'zone', + ) + if (delegation?.zone_perm_add_records === 1) return allow() + if (delegation) { + return deny(`Not allowed to add records to the delegated zone.`) + } + } + return deny( + `No Access Allowed to that object` + + ` (${DELEGATE_TYPE[resource] ?? 'GROUP'} : ${targetGid})`, + ) + } + } + return allow() + } + + if (resource === 'user' && objectId === credentials.user.id) { + if (action === 'delete') return deny(`Not allowed to delete self`) + if (action === 'write') { + if (perm.self_write !== true) return deny(`Not allowed to modify self`) + return allow() + } + return allow() + } + + if (resource === 'group' && objectId === credentials.group.id) { + if (action === 'write') return deny(`Not allowed to edit your own group`) + if (action === 'delete') return deny(`Not allowed to delete your own group`) + if (action === 'read') return allow() + } + + if ( + resource === 'nameserver' + && action === 'read' + && await this.isActiveObject(resource, objectId) + ) { + return allow() + } + + if ( + ['delegate', 'editDelegation', 'deleteDelegation'].includes(action) + && !await this.isActiveObject(resource, objectId) + ) { + return deny(`Cannot change delegation for a deleted object`) + } + + const objGroupId = await this.getObjectGroupId(resource, objectId) + if (objGroupId === null) { + return deny(`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`) + } + + if (await this.isInGroupTree(credentials.group.id, objGroupId)) { + if (action === 'read') return allow() + const permissionAction = ACTION_PERMISSION[action] ?? action + if (perm[resource]?.[permissionAction] === true) return allow() + return deny(`You have no '${action}' permission for ${resource} objects`) + } + + const delegation = await this.getDelegateAccess( + credentials.group.id, objectId, resource, + ) + if (delegation) { + if (action === 'read') return allow({ delegation }) + const displayAction = ACTION_PERMISSION[action] ?? action + if (action === 'editDelegation') { + return deny(`You have no '${displayAction}' permission for the delegated object`) + } + // v2 sets pseudo => 'none' on every delegate action: access inherited + // from a parent object never carries the right to delegate + if (delegation.pseudo && action === 'delegate') { + return deny(`You have no '${action}' permission for the delegated object`) + } + if (resource === 'zonerecord' && delegation.pseudo && action === 'delete') { + if (delegation.zone_perm_delete_records === 1) return allow() + return deny(`You have no '${action}' permission for the delegated object`) + } + if (action === 'delete') { + return deny(`You have no '${action}' permission for the delegated object`) + } + const permField = action === 'deleteDelegation' + ? 'perm_delete' + : `perm_${action}` + if (delegation[permField] === 1) return allow() + return deny(`You have no '${displayAction}' permission for the delegated object`) + } + + return deny( + `No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`, + ) + } + + async getObjectGroupId(resource, objectId) { + const query = RESOURCE_QUERIES[resource] + if (!query) return null + + const rows = await Mysql.execute(query, [objectId]) + if (rows.length === 0) return null + + let gid = rows[0].nt_group_id + if (resource === 'group' && (gid === 0 || gid === null)) gid = 1 + return gid + } + + async isInGroupTree(userGroupId, targetGroupId) { + if (userGroupId === targetGroupId) return true + + const rows = await Mysql.execute( + `SELECT COUNT(*) AS count FROM nt_group_subgroups + WHERE nt_group_id = ? AND nt_subgroup_id = ?`, + [userGroupId, targetGroupId], + ) + return rows[0].count > 0 + } + + async isActiveGroup(groupId) { + const rows = await Mysql.execute( + 'SELECT 1 FROM nt_group WHERE nt_group_id = ? AND deleted = 0', + [groupId], + ) + return rows.length > 0 + } + + async isActiveObject(resource, objectId) { + const table = delegateTable(resource) + const idColumn = delegateIdColumn(resource)?.slice(2) + if (!table || !idColumn) return false + const rows = await Mysql.execute( + `SELECT 1 FROM ${table} WHERE ${idColumn} = ? AND deleted = 0`, + [objectId], + ) + return rows.length > 0 + } + + async getDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + + const direct = await this.getDirectDelegateAccess(groupId, objectId, resource) + if (direct) return direct + + if (resource === 'zonerecord') { + return this.getZoneRecordPseudoDelegation(groupId, objectId) + } + if (resource === 'zone') { + return this.getZonePseudoDelegation(groupId, objectId) + } + return null + } + + async getDirectDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + const rows = await Mysql.execute( + `SELECT d.* FROM nt_delegate d + JOIN ${delegateTable(resource)} o ON ${delegateIdColumn(resource)} = d.nt_object_id + WHERE d.nt_group_id = ? AND d.nt_object_id = ? AND d.nt_object_type = ? + AND d.deleted = 0 AND o.deleted = 0`, + [groupId, objectId, type], + ) + return rows.length > 0 ? rows[0] : null + } + + async getDelegatedZoneIds(groupIds) { + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]) + .map(Number) + .filter(Number.isInteger) + if (gids.length === 0) return [] + const placeholders = gids.map(() => '?').join(', ') + const rows = await Mysql.execute( + `SELECT d.nt_object_id AS id + FROM nt_delegate d + JOIN nt_zone z ON z.nt_zone_id = d.nt_object_id + WHERE d.nt_group_id IN (${placeholders}) + AND d.nt_object_type = 'ZONE' AND d.deleted = 0 AND z.deleted = 0 + UNION + SELECT r.nt_zone_id AS id + FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id IN (${placeholders}) + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [...gids, ...gids], + ) + return rows.map((row) => row.id) + } + + async getZoneRecordReadScope(groupId, zoneId) { + const objectGroupId = await this.getObjectGroupId('zone', zoneId) + if (objectGroupId === null) return [] + if (await this.isInGroupTree(groupId, objectGroupId)) return null + if (await this.getDirectDelegateAccess(groupId, zoneId, 'zone')) return null + + const rows = await Mysql.execute( + `SELECT r.nt_zone_record_id AS id + FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? AND r.nt_zone_id = ? + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [groupId, zoneId], + ) + return rows.map((row) => row.id) + } + + // v2 grants read on a zone to any group holding a delegation on one of its + // records. Every permission is 0, so only the read fast-paths above accept it. + async getZonePseudoDelegation(groupId, zoneId) { + const rows = await Mysql.execute( + `SELECT 1 FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? + AND z.nt_zone_id = ? + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0 + LIMIT 1`, + [groupId, zoneId], + ) + if (rows.length === 0) return null + return { + pseudo: 1, + perm_write: 0, + perm_delete: 0, + perm_delegate: 0, + zone_perm_add_records: 0, + zone_perm_delete_records: 0, + } + } + + async getZoneRecordPseudoDelegation(groupId, zoneRecordId) { + const rows = await Mysql.execute( + `SELECT d.*, 1 AS pseudo FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? + AND r.nt_zone_record_id = ? + AND d.nt_object_type = 'ZONE' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [groupId, zoneRecordId], + ) + return rows.length > 0 ? rows[0] : null + } + + capPermissions(userPerm, targetPerms, existingPerm) { + if (!targetPerms || !userPerm) return targetPerms + + const capped = { ...targetPerms } + for (const field of PERM_FIELDS) { + if (capped[field] === undefined) continue + const [resource] = field.split('_', 2) + const remaining = field.slice(resource.length + 1) + if (userPerm[resource]?.[remaining] !== true) { + delete capped[field] + } + } + + for (const resource of ['group', 'nameserver', 'user', 'zone', 'zonerecord']) { + if (!capped[resource]) continue + capped[resource] = { ...capped[resource] } + for (const action of ['create', 'write', 'delete', 'delegate']) { + if (capped[resource][action] === undefined) continue + if (userPerm[resource]?.[action] !== true) delete capped[resource][action] + } + } + + if (capped.self_write !== undefined && userPerm.user?.write !== true) { + delete capped.self_write + } + + const usable = userPerm.nameserver?.usable ?? [] + const existingUsable = existingPerm?.nameserver?.usable ?? [] + if (Array.isArray(capped.usable_ns)) { + capped.usable_ns = capUsableNameservers(capped.usable_ns, usable, existingUsable) + } + if (Array.isArray(capped.nameserver?.usable)) { + capped.nameserver.usable = capUsableNameservers( + capped.nameserver.usable, usable, existingUsable, + ) + } + return capped + } + + canTransitionPermissions(userPerm, before, after) { + for (const field of PERM_FIELDS) { + const [resource] = field.split('_', 1) + const action = field.slice(resource.length + 1) + if (userPerm[resource]?.[action] === true) continue + if (Boolean(before?.[resource]?.[action]) !== Boolean(after?.[resource]?.[action])) { + return false + } + } + + if ( + userPerm.user?.write !== true + && Boolean(before?.self_write) !== Boolean(after?.self_write) + ) { + return false + } + + const allowed = new Set((userPerm.nameserver?.usable ?? []).map(String)) + const oldUsable = new Set((before?.nameserver?.usable ?? []).map(String)) + const newUsable = new Set((after?.nameserver?.usable ?? []).map(String)) + for (const id of new Set([...oldUsable, ...newUsable])) { + if (oldUsable.has(id) !== newUsable.has(id) && !allowed.has(id)) return false + } + return true + } + + preserveUnmanagedPermissions(userPerm, targetPerms, currentPerm) { + const preserved = { ...targetPerms } + for (const field of PERM_FIELDS) { + const [resource] = field.split('_', 1) + const action = field.slice(resource.length + 1) + if (userPerm[resource]?.[action] !== true) { + preserved[field] = Boolean(currentPerm?.[resource]?.[action]) + } + } + if (userPerm.user?.write !== true) { + preserved.self_write = Boolean(currentPerm?.self_write) + } + return preserved + } + + async getCurrentCredentials(credentials) { + // Match the JWT's maximum token age when checking server-side revocation. + const oldest = Math.floor(Date.now() / 1000) - SESSION_MAX_AGE_SEC + const rows = await Mysql.execute( + `SELECT u.nt_group_id AS gid + FROM nt_user u + JOIN nt_group g ON g.nt_group_id = u.nt_group_id + JOIN nt_user_session s ON s.nt_user_id = u.nt_user_id + WHERE u.nt_user_id = ? AND s.nt_user_session_id = ? + AND u.deleted = 0 AND g.deleted = 0 + AND s.last_access >= ?`, + [credentials.user.id, credentials.session.id, oldest], + ) + if (rows.length === 0) return null + return { + ...credentials, + group: { ...credentials.group, id: rows[0].gid }, + } + } + + async checkPermissionRecord(credentials, action, permissionId) { + const rows = await Mysql.execute( + `SELECT NULLIF(p.nt_user_id, 0) AS uid, + NULLIF(p.nt_group_id, 0) AS gid, + COALESCE(NULLIF(p.nt_group_id, 0), u.nt_group_id) AS target_gid + FROM nt_perm p + LEFT JOIN nt_user u ON u.nt_user_id = p.nt_user_id + WHERE p.nt_perm_id = ?`, + [permissionId], + ) + if (rows.length === 0 || rows[0].target_gid === null) { + return deny(`No Access Allowed to that permission (${permissionId})`) + } + + if (action === 'read') { + return await this.isInGroupTree(credentials.group.id, rows[0].target_gid) + ? allow() + : deny(`No Access Allowed to that permission (${permissionId})`) + } + + if (rows[0].uid !== null) { + if (rows[0].uid === credentials.user.id) { + return deny(`Not allowed to modify your own permissions`) + } + if (!await this.isActiveObject('user', rows[0].uid)) { + return deny(`Cannot modify permissions for a deleted user`) + } + return this.checkPermission(credentials, 'user', 'write', rows[0].uid) + } + if (!await this.isActiveGroup(rows[0].gid)) { + return deny(`Cannot modify permissions for a deleted group`) + } + return this.checkPermission(credentials, 'group', 'write', rows[0].gid) + } + + async checkPermissionTarget(credentials, payload) { + const uid = payload.user?.id + if (uid !== undefined && uid !== null) { + if (uid === credentials.user.id) { + return deny(`Not allowed to modify your own permissions`) + } + if (!await this.isActiveObject('user', uid)) { + return deny(`Cannot create permissions for a deleted user`) + } + // the row is stored with both ids; an unrelated gid would put it outside + // the tree that owns the user, so only the user's own group is accepted + const payloadGid = payload.group?.id + if (payloadGid !== undefined && payloadGid !== null) { + const userGid = await this.getObjectGroupId('user', uid) + if (Number(payloadGid) !== userGid) { + return deny(`That permission target does not belong to that group`) + } + } + return this.checkPermission(credentials, 'user', 'write', uid) + } + + const gid = payload.group?.id + if (gid === undefined || gid === null) return deny(`No permission target found`) + if (!await this.isActiveGroup(gid)) { + return deny(`Cannot create permissions for a deleted group`) + } + return this.checkPermission(credentials, 'group', 'write', gid) + } +} + +function delegateTable(resource) { + return { + zone: 'nt_zone', + zonerecord: 'nt_zone_record', + nameserver: 'nt_nameserver', + group: 'nt_group', + user: 'nt_user', + }[resource] +} + +function delegateIdColumn(resource) { + return { + zone: 'o.nt_zone_id', + zonerecord: 'o.nt_zone_record_id', + nameserver: 'o.nt_nameserver_id', + group: 'o.nt_group_id', + user: 'o.nt_user_id', + }[resource] +} + +function allow(extra = {}) { + return { allowed: true, ...extra } +} + +function deny(msg) { + return { allowed: false, code: 404, msg } +} + +function capUsableNameservers(requested, allowed, existing) { + const allowedIds = new Set(allowed.map(String)) + const result = requested.map(String).filter((id) => allowedIds.has(id)) + for (const id of existing.map(String)) { + if (!allowedIds.has(id) && !result.includes(id)) result.push(id) + } + return result +} + +export default new Authz() diff --git a/lib/authz.test.js b/lib/authz.test.js new file mode 100644 index 0000000..a1fc345 --- /dev/null +++ b/lib/authz.test.js @@ -0,0 +1,583 @@ +import assert from 'node:assert/strict' +import { describe, it, after, before } from 'node:test' + +import Group from './group/index.js' +import User from './user/index.js' +import Zone from './zone/index.js' +import ZoneRecord from './zone_record/index.js' +import Nameserver from './nameserver/index.js' +import Permission from './permission/index.js' +import Delegation from './delegation.js' +import Authz from './authz.js' +import Mysql from './mysql.js' + +const G_ROOT = { + id: 4200, + parent_gid: 0, + name: 'authz-root', +} +const G_CHILD = { + id: 4201, + parent_gid: 4200, + name: 'authz-child', +} +const G_OUTSIDE = { + id: 4202, + parent_gid: 0, + name: 'authz-outside', +} + +const U_FULL = { + id: 4200, + gid: 4200, + username: 'authz-full', + email: 'authz-full@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'Full', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_LIMITED = { + id: 4201, + gid: 4202, + username: 'authz-limited', + email: 'authz-limited@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'Limited', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_NOSELF = { + id: 4202, + gid: 4200, + username: 'authz-noself', + email: 'authz-noself@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'No', + last_name: 'Self', + inherit_group_permissions: false, +} + +const Z_INTREE = { + id: 4200, + gid: 4200, + zone: 'authz.example.com.', + mailaddr: 'hostmaster.authz.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const Z_OUTSIDE = { + id: 4201, + gid: 4202, + zone: 'authz-out.example.com.', + mailaddr: 'hostmaster.authz-out.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} + +const ZR_INTREE = { + id: 4200, + zid: 4200, + owner: 'test.authz.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const ZR_PSEUDO = { + id: 4201, + zid: 4201, + owner: 'test.authz-out.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} +const ZR_DIRECT = { + id: 4202, + zid: 4201, + owner: 'direct.authz-out.example.com.', + type: 'A', + address: '192.0.2.3', + ttl: 3600, +} + +const NS = { + id: 4200, + gid: 4200, + name: 'ns1.authz.example.com.', + ttl: 3600, + description: 'authz test ns', + address: '192.0.2.10', + export: { type: 'bind', interval: 0, serials: 0 }, +} + +// Credentials objects matching JWT shape +const credsFull = { user: { id: 4200 }, group: { id: 4200 } } +const credsLimited = { user: { id: 4201 }, group: { id: 4202 } } +const credsNoself = { user: { id: 4202 }, group: { id: 4200 } } + +before(async () => { + // Clean up stale data from prior crashed runs + for (const d of [ + { gid: 4200, oid: 4202, type: 'ZONERECORD' }, + { gid: 4200, oid: 4201, type: 'ZONE' }, + ]) { + try { await Delegation.delete(d) } catch { /* ignore */ } + } + for (const id of [4200, 4201, 4202]) { + await ZoneRecord.destroy({ id }) + } + for (const id of [4200, 4201]) await Zone.destroy({ id }) + await Nameserver.destroy({ id: 4200 }) + for (const id of [4200, 4201, 4202]) { + const p = await Permission.get({ uid: id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id }) + } + for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + + for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) + for (const u of [U_FULL, U_LIMITED, U_NOSELF]) await User.create(u) + + // Set permissions for full-perm user + const fullPerm = await Permission.get({ uid: U_FULL.id }) + if (fullPerm) { + await Permission.put({ + id: fullPerm.id, + self_write: 1, + group_write: 1, group_create: 1, group_delete: 1, + zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, + zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, user_create: 1, user_delete: 1, + nameserver_write: 1, nameserver_create: 1, nameserver_delete: 1, + usable_ns: '4200', + }) + } + + // Set permissions for limited user — all false (defaults) + const limPerm = await Permission.get({ uid: U_LIMITED.id }) + if (limPerm) { + await Permission.put({ + id: limPerm.id, + self_write: 0, + group_write: 0, group_create: 0, group_delete: 0, + zone_write: 0, zone_create: 0, zone_delete: 0, zone_delegate: 0, + zonerecord_write: 0, zonerecord_create: 0, zonerecord_delete: 0, + zonerecord_delegate: 0, + user_write: 0, user_create: 0, user_delete: 0, + nameserver_write: 0, nameserver_create: 0, nameserver_delete: 0, + usable_ns: '', + }) + } + + // Set permissions for noself user — has resource perms but no self_write + const noselfPerm = await Permission.get({ uid: U_NOSELF.id }) + if (noselfPerm) { + await Permission.put({ + id: noselfPerm.id, + self_write: 0, + zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, + zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, user_create: 1, user_delete: 1, + }) + } + + // Create zones, zone records, nameserver + await Zone.create(Z_INTREE) + await Zone.create(Z_OUTSIDE) + await ZoneRecord.create(ZR_INTREE) + await ZoneRecord.create(ZR_PSEUDO) + await ZoneRecord.create(ZR_DIRECT) + await Nameserver.create(NS) + + // Create delegations + await Delegation.create({ + gid: 4200, oid: 4201, type: 'ZONE', + perm_write: true, perm_delete: false, perm_delegate: true, + }) + await Delegation.create({ + gid: 4200, oid: 4202, type: 'ZONERECORD', + perm_write: true, perm_delete: false, perm_delegate: false, + }) +}) + +after(async () => { + // Teardown in reverse dependency order + await Delegation.delete({ gid: 4200, oid: 4202, type: 'ZONERECORD' }) + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + await Nameserver.destroy({ id: NS.id }) + await ZoneRecord.destroy({ id: ZR_DIRECT.id }) + await ZoneRecord.destroy({ id: ZR_PSEUDO.id }) + await ZoneRecord.destroy({ id: ZR_INTREE.id }) + await Zone.destroy({ id: Z_OUTSIDE.id }) + await Zone.destroy({ id: Z_INTREE.id }) + for (const u of [U_NOSELF, U_LIMITED, U_FULL]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { + await Group.destroy({ id: g.id }) + } + // Clean up subgroup entries + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + await Mysql.disconnect() +}) + +describe('checkPermission', () => { + describe('create actions', () => { + it('allows create when user has permission', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'create', undefined, + { targetGroupId: 4200 }, + ) + assert.equal(r.allowed, true) + }) + + it('allows create into child group', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'create', undefined, + { targetGroupId: 4201 }, + ) + assert.equal(r.allowed, true) + }) + + it('denies create when user lacks permission', async () => { + const r = await Authz.checkPermission( + credsLimited, 'zone', 'create', undefined, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to create/) + }) + + it('denies create when target group not in tree', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'create', undefined, + { targetGroupId: 4202 }, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + }) + + describe('self-user restrictions', () => { + it('denies delete self', async () => { + const r = await Authz.checkPermission( + credsFull, 'user', 'delete', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to delete self/) + }) + + it('allows write self when self_write=true', async () => { + const r = await Authz.checkPermission( + credsFull, 'user', 'write', 4200, + ) + assert.equal(r.allowed, true) + }) + + it('denies write self when self_write=false', async () => { + const r = await Authz.checkPermission( + credsNoself, 'user', 'write', 4202, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to modify self/) + }) + + it('allows read self', async () => { + const r = await Authz.checkPermission( + credsFull, 'user', 'read', 4200, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('own-group restrictions', () => { + it('denies write to own group', async () => { + const r = await Authz.checkPermission( + credsFull, 'group', 'write', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to edit your own group/) + }) + + it('denies delete own group', async () => { + const r = await Authz.checkPermission( + credsFull, 'group', 'delete', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to delete your own group/) + }) + }) + + describe('nameserver reads', () => { + it('allows authenticated read of an active nameserver', async () => { + const r = await Authz.checkPermission( + credsLimited, 'nameserver', 'read', 4200, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('group tree ownership', () => { + it('allows read of in-tree zone', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'read', 4200, + ) + assert.equal(r.allowed, true) + }) + + it('allows write of in-tree zone with permission', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'write', 4200, + ) + assert.equal(r.allowed, true) + }) + + it('denies write when user lacks action permission', async () => { + const r = await Authz.checkPermission( + credsLimited, 'zone', 'write', 4201, + ) + assert.equal(r.allowed, false) + }) + }) + + describe('delegation access', () => { + it('allows read of delegated zone', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'read', 4201, + ) + assert.equal(r.allowed, true) + }) + + it('allows write of delegated zone when perm_write=1', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'write', 4201, + ) + assert.equal(r.allowed, true) + }) + + it('denies delete of delegated zone when perm_delete=0', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'delete', 4201, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /no 'delete' permission/) + }) + + it('perm_delete never permits deleting the delegated object', async () => { + await Delegation.put({ + gid: 4200, oid: 4201, type: 'ZONE', perm_delete: true, + }) + const r = await Authz.checkPermission( + credsFull, 'zone', 'delete', 4201, + ) + assert.equal(r.allowed, false) + await Delegation.put({ + gid: 4200, oid: 4201, type: 'ZONE', perm_delete: false, + }) + }) + + it('allows delegate action when perm_delegate=1', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'delegate', 4201, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('pseudo-delegation (zone record via parent zone)', () => { + it('allows read of zone record in delegated zone', async () => { + const r = await Authz.checkPermission( + credsFull, 'zonerecord', 'read', 4201, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('direct zone record delegation', () => { + it('allows read of directly delegated zone record', async () => { + const r = await Authz.checkPermission( + credsFull, 'zonerecord', 'read', 4202, + ) + assert.equal(r.allowed, true) + }) + + it('denies delete when perm_delete=0', async () => { + const r = await Authz.checkPermission( + credsFull, 'zonerecord', 'delete', 4202, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /no 'delete' permission/) + }) + }) + + describe('deny fallthrough', () => { + it('denies access to object not in tree and not delegated', async () => { + const r = await Authz.checkPermission( + credsLimited, 'zone', 'read', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + + it('denies when object does not exist', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'read', 99999, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + }) +}) + +describe('getObjectGroupId', () => { + it('returns group id for zone', async () => { + assert.equal(await Authz.getObjectGroupId('zone', 4200), 4200) + }) + + it('returns group id for zonerecord via join', async () => { + assert.equal(await Authz.getObjectGroupId('zonerecord', 4200), 4200) + }) + + it('returns group id for user', async () => { + assert.equal(await Authz.getObjectGroupId('user', 4200), 4200) + }) + + it('returns group id for nameserver', async () => { + assert.equal(await Authz.getObjectGroupId('nameserver', 4200), 4200) + }) + + it('returns parent_group_id for group', async () => { + assert.equal(await Authz.getObjectGroupId('group', 4201), 4200) + }) + + it('returns 1 for root group', async () => { + assert.equal(await Authz.getObjectGroupId('group', 4200), 1) + }) + + it('returns null for unknown resource type', async () => { + assert.equal(await Authz.getObjectGroupId('bogus', 4200), null) + }) + + it('returns null for nonexistent object', async () => { + assert.equal(await Authz.getObjectGroupId('zone', 99999), null) + }) +}) + +describe('isInGroupTree', () => { + it('returns true for same group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4200), true) + }) + + it('returns true for child group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4201), true) + }) + + it('returns false for unrelated group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4202), false) + }) + + it('returns false for parent from child perspective', async () => { + assert.equal(await Authz.isInGroupTree(4201, 4200), false) + }) +}) + +describe('getDelegateAccess', () => { + it('returns delegation row for directly delegated zone', async () => { + const d = await Authz.getDelegateAccess(4200, 4201, 'zone') + assert.ok(d) + assert.equal(d.perm_write, 1) + assert.equal(d.perm_delete, 0) + }) + + it('returns null for non-delegated zone', async () => { + const d = await Authz.getDelegateAccess(4202, 4200, 'zone') + assert.equal(d, null) + }) + + it('returns pseudo-delegation for zone record via parent zone', async () => { + const d = await Authz.getDelegateAccess(4200, 4201, 'zonerecord') + assert.ok(d) + assert.equal(d.pseudo, 1) + }) + + it('returns direct delegation for zone record', async () => { + const d = await Authz.getDelegateAccess(4200, 4202, 'zonerecord') + assert.ok(d) + assert.equal(d.perm_write, 1) + assert.equal(d.perm_delete, 0) + }) + + it('returns null for unknown resource type', async () => { + const d = await Authz.getDelegateAccess(4200, 4200, 'bogus') + assert.equal(d, null) + }) +}) + +describe('capPermissions', () => { + it('removes fields user lacks permission for', () => { + const userPerm = { + zone: { create: true, write: false, delete: true }, + user: { create: false }, + } + const target = { + zone_create: 1, + zone_write: 1, + zone_delete: 1, + user_create: 1, + } + const capped = Authz.capPermissions(userPerm, target) + assert.equal(capped.zone_create, 1) + assert.equal(capped.zone_write, undefined) + assert.equal(capped.zone_delete, 1) + assert.equal(capped.user_create, undefined) + }) + + it('returns null/undefined inputs as-is', () => { + assert.equal(Authz.capPermissions({}, null), null) + assert.equal(Authz.capPermissions({}, undefined), undefined) + }) + + it('preserves usable nameservers the caller cannot manage', () => { + const capped = Authz.capPermissions( + { nameserver: { usable: ['1'] } }, + { usable_ns: [1] }, + { nameserver: { usable: ['1', '2'] } }, + ) + assert.deepEqual(capped.usable_ns, ['1', '2']) + }) + + it('rejects an inherited-permission transition the caller cannot grant', () => { + const userPerm = { zone: { delete: false }, user: { write: true } } + const before = { zone: { delete: false } } + const after = { zone: { delete: true } } + assert.equal(Authz.canTransitionPermissions(userPerm, before, after), false) + assert.equal(Authz.canTransitionPermissions(userPerm, after, after), true) + }) + + it('carries unmanaged permissions into a new explicit row', () => { + const preserved = Authz.preserveUnmanagedPermissions( + { zone: { delete: false }, user: { write: true } }, + { name: 'explicit' }, + { zone: { delete: true } }, + ) + assert.equal(preserved.zone_delete, true) + }) +}) diff --git a/lib/delegation.js b/lib/delegation.js new file mode 100644 index 0000000..abe4023 --- /dev/null +++ b/lib/delegation.js @@ -0,0 +1,218 @@ +import Mysql from './mysql.js' + +const TYPE_META = { + ZONE: { table: 'nt_zone', idCol: 'nt_zone_id' }, + ZONERECORD: { table: 'nt_zone_record', idCol: 'nt_zone_record_id' }, + NAMESERVER: { table: 'nt_nameserver', idCol: 'nt_nameserver_id' }, + GROUP: { table: 'nt_group', idCol: 'nt_group_id' }, +} + +const PERM_FIELDS = [ + 'perm_write', + 'perm_delete', + 'perm_delegate', + 'zone_perm_add_records', + 'zone_perm_delete_records', +] + +class Delegation { + constructor() { + this.mysql = Mysql + } + + async create(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT nt_group_id FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length > 0) return { duplicate: true } + + const row = { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + delegated_by_id: args.delegated_by_id ?? 0, + delegated_by_name: args.delegated_by_name ?? '', + } + + for (const f of PERM_FIELDS) { + row[f] = args[f] === true ? 1 : 0 + } + + await Mysql.execute(...Mysql.insert('nt_delegate', row)) + + await this.log(row, 'delegated') + + return { created: true } + } + + async get(args) { + const { gid, oid, type } = args + const objType = type ?? 'ZONE' + const meta = TYPE_META[objType] + if (!meta) return [] + + if (oid !== undefined) { + return this.getDelegates(oid, objType, gid) + } + if (gid !== undefined) { + return this.getDelegated(gid, objType, meta) + } + return [] + } + + async getDelegated(gid, objType, meta) { + const query = `SELECT + d.nt_group_id, + d.nt_object_id, + d.nt_object_type, + g.name AS group_name, + d.delegated_by_id, + d.delegated_by_name, + d.perm_write AS delegate_write, + d.perm_delete AS delegate_delete, + d.perm_delegate AS delegate_delegate, + d.zone_perm_add_records AS delegate_add_records, + d.zone_perm_delete_records AS delegate_delete_records, + o.${meta.idCol} AS ${meta.idCol} + FROM nt_delegate d + JOIN ${meta.table} o ON o.${meta.idCol} = d.nt_object_id + JOIN nt_group g ON g.nt_group_id = d.nt_group_id + WHERE d.nt_object_type = ? + AND d.nt_group_id = ? + AND d.deleted = 0 + AND o.deleted = 0 + AND g.deleted = 0` + + return Mysql.execute(query, [objType, gid]) + } + + async getDelegates(oid, objType, gid) { + const query = `SELECT + d.nt_group_id, + d.nt_object_id, + d.nt_object_type, + g.name AS group_name, + d.delegated_by_id, + d.delegated_by_name, + d.perm_write AS delegate_write, + d.perm_delete AS delegate_delete, + d.perm_delegate AS delegate_delegate, + d.zone_perm_add_records AS delegate_add_records, + d.zone_perm_delete_records AS delegate_delete_records + FROM nt_delegate d + JOIN nt_group g ON g.nt_group_id = d.nt_group_id + WHERE d.nt_object_type = ? + AND d.nt_object_id = ? + AND d.deleted = 0 + AND g.deleted = 0` + + if (gid === undefined) return Mysql.execute(query, [objType, oid]) + return Mysql.execute(`${query} AND d.nt_group_id = ?`, [objType, oid, gid]) + } + + async put(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT nt_group_id, perm_write, perm_delete, perm_delegate, + zone_perm_add_records, zone_perm_delete_records + FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length === 0) return null + + const updates = {} + for (const f of PERM_FIELDS) { + if (args[f] !== undefined) { + updates[f] = args[f] === true ? 1 : 0 + } + } + + if (Object.keys(updates).length === 0) return true + + const setClauses = Object.keys(updates) + .map((k) => `${k} = ?`) + .join(', ') + const values = [...Object.values(updates), gid, oid, type] + + await Mysql.execute( + `UPDATE nt_delegate SET ${setClauses} + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + values, + ) + + await this.log( + { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + ...existing[0], + delegated_by_id: args.delegated_by_id, + delegated_by_name: args.delegated_by_name, + ...updates, + }, + 'modified', + ) + + return true + } + + async delete(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT nt_group_id, perm_write, perm_delete, perm_delegate, + zone_perm_add_records, zone_perm_delete_records + FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length === 0) return null + + await this.log( + { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + ...existing[0], + delegated_by_id: args.delegated_by_id, + delegated_by_name: args.delegated_by_name, + }, + 'deleted', + ) + + await Mysql.execute( + `DELETE FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ?`, + [gid, oid, type], + ) + + return true + } + + async log(data, action) { + const row = { + nt_user_id: data.delegated_by_id ?? 0, + nt_user_name: data.delegated_by_name ?? '', + action, + nt_object_type: data.nt_object_type, + nt_object_id: data.nt_object_id, + nt_group_id: data.nt_group_id, + timestamp: Math.floor(Date.now() / 1000), + perm_write: data.perm_write ?? 1, + perm_delete: data.perm_delete ?? 1, + perm_delegate: data.perm_delegate ?? 1, + zone_perm_add_records: data.zone_perm_add_records ?? 1, + zone_perm_delete_records: data.zone_perm_delete_records ?? 1, + } + + await Mysql.execute(...Mysql.insert('nt_delegate_log', row)) + } +} + +export default new Delegation() diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index 611810e..e18adf7 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -157,10 +157,50 @@ class Group extends GroupBase { if (Object.keys(args).length === 0) return true - const r = await Mysql.execute( + const update = () => Mysql.execute( ...Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)), ) - return r.changedRows === 1 + + if (args.parent_gid === undefined) { + const r = await update() + return r.changedRows === 1 + } + + await Mysql.execute('START TRANSACTION') + try { + const r = await update() + await this.rebuildSubgroups(id) + await Mysql.execute('COMMIT') + return r.changedRows === 1 + } catch (err) { + await Mysql.execute('ROLLBACK') + throw err + } + } + + async rebuildSubgroups(rootGid) { + const groups = await Mysql.execute( + `WITH RECURSIVE descendants AS ( + SELECT nt_group_id AS id, parent_group_id AS parent_gid + FROM nt_group WHERE nt_group_id = ? + UNION ALL + SELECT g.nt_group_id, g.parent_group_id + FROM nt_group g + JOIN descendants d ON g.parent_group_id = d.id + ) + SELECT id, parent_gid FROM descendants`, + [rootGid], + ) + if (groups.length === 0) return + + const placeholders = groups.map(() => '?').join(', ') + await Mysql.execute( + `DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (${placeholders})`, + groups.map((group) => group.id), + ) + for (const group of groups) { + await this.addToSubgroups(group.id, group.parent_gid) + } } async delete(args) { @@ -174,7 +214,10 @@ class Group extends GroupBase { async destroy(args) { // Clean up associated permission and subgroup-closure rows before removing the group - await Mysql.execute(`DELETE FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL`, [args.id]) + await Mysql.execute( + `DELETE FROM nt_perm WHERE nt_group_id = ? AND (nt_user_id IS NULL OR nt_user_id = 0)`, + [args.id], + ) await Mysql.execute(`DELETE FROM nt_group_subgroups WHERE nt_group_id = ? OR nt_subgroup_id = ?`, [ args.id, args.id, diff --git a/lib/group/test/index.js b/lib/group/test/index.js index d39ab54..9441a53 100644 --- a/lib/group/test/index.js +++ b/lib/group/test/index.js @@ -9,8 +9,26 @@ import groupJson from '../test/group.json' with { type: 'json' } // rather than the shared fixture that the concurrently-run user and permission // suites depend on staying live and named. const testCase = { ...groupJson, id: 4088, name: 'grouptest.example.com' } +const moveParentA = { ...groupJson, id: 4070, name: 'group-move-a.example.com' } +const moveParentB = { ...groupJson, id: 4071, name: 'group-move-b.example.com' } +const moveChild = { + ...groupJson, + id: 4072, + parent_gid: moveParentA.id, + name: 'group-move-child.example.com', +} +const moveGrandchild = { + ...groupJson, + id: 4073, + parent_gid: moveChild.id, + name: 'group-move-grandchild.example.com', +} after(async () => { + await Group.destroy({ id: moveGrandchild.id }) + await Group.destroy({ id: moveChild.id }) + await Group.destroy({ id: moveParentB.id }) + await Group.destroy({ id: moveParentA.id }) await Group.destroy({ id: testCase.id }) Group.disconnect() }) @@ -18,6 +36,10 @@ after(async () => { describe('group', function () { before(async () => { await Group.create(testCase) + await Group.create(moveParentA) + await Group.create(moveParentB) + await Group.create(moveChild) + await Group.create(moveGrandchild) }) it('gets group by id', async () => { @@ -44,6 +66,20 @@ describe('group', function () { assert.ok(await Group.put({ id: testCase.id, name: testCase.name })) }) + it('rebuilds authorization ancestry when a group moves', async () => { + assert.ok((await Group.subgroupGids(moveParentA.id)).includes(moveChild.id)) + assert.ok(!(await Group.subgroupGids(moveParentB.id)).includes(moveChild.id)) + + assert.ok(await Group.put({ id: moveChild.id, parent_gid: moveParentB.id })) + + const oldBranch = await Group.subgroupGids(moveParentA.id) + const newBranch = await Group.subgroupGids(moveParentB.id) + assert.ok(!oldBranch.includes(moveChild.id)) + assert.ok(!oldBranch.includes(moveGrandchild.id)) + assert.ok(newBranch.includes(moveChild.id)) + assert.ok(newBranch.includes(moveGrandchild.id)) + }) + it('deletes a group', async () => { assert.ok(await Group.delete({ id: testCase.id })) let g = await Group.get({ id: testCase.id, deleted: 1 }) diff --git a/lib/permission/store/mysql.js b/lib/permission/store/mysql.js index 3f673c2..9919333 100644 --- a/lib/permission/store/mysql.js +++ b/lib/permission/store/mysql.js @@ -10,6 +10,14 @@ const permDbMap = { name: 'perm_name', } +const permissionColumns = [ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', +] + class PermissionRepoMySQL extends PermissionBase { constructor(args = {}) { super(args) @@ -18,22 +26,54 @@ class PermissionRepoMySQL extends PermissionBase { async create(args) { if (args.id) { - const p = await this.get({ id: args.id }) - if (p) return p.id + const rows = await Mysql.execute( + 'SELECT nt_perm_id, deleted FROM nt_perm WHERE nt_perm_id = ? LIMIT 1', + [args.id], + ) + if (rows.length > 0) return this.reuse(rows[0], args) } - // Deduplicate group-level permission rows (uid IS NULL) to prevent accumulation + // v2 uses uid=0 for group rows; v3-created rows use NULL. if (args.gid !== undefined && args.uid === undefined) { const rows = await Mysql.execute( - `SELECT nt_perm_id FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL LIMIT 1`, + `SELECT nt_perm_id, deleted FROM nt_perm + WHERE nt_group_id = ? AND (nt_user_id IS NULL OR nt_user_id = 0) + ORDER BY deleted, nt_perm_id LIMIT 1`, [args.gid], ) - if (rows.length > 0) return rows[0].nt_perm_id + if (rows.length > 0) return this.reuse(rows[0], args) + } + + // ...and user-level rows: a second one makes get({uid}) throw, which would + // then fail every request that user makes + if (args.uid !== undefined && args.uid !== null) { + const rows = await Mysql.execute( + `SELECT nt_perm_id, deleted FROM nt_perm + WHERE nt_user_id = ? ORDER BY deleted, nt_perm_id LIMIT 1`, + [args.uid], + ) + if (rows.length > 0) return this.reuse(rows[0], args) } return await Mysql.execute(...Mysql.insert(`nt_perm`, mapToDbColumn(objectToDb(args), permDbMap))) } + async reuse(row, args) { + if (row.deleted === 1) { + const replacement = Object.fromEntries(permissionColumns.map((field) => [field, 0])) + Object.assign(replacement, { + self_write: 0, + usable_ns: '', + inherit_perm: 0, + ...mapToDbColumn(objectToDb(args), permDbMap), + deleted: 0, + }) + delete replacement.nt_perm_id + await Mysql.execute(...Mysql.update('nt_perm', `nt_perm_id=${row.nt_perm_id}`, replacement)) + } + return row.nt_perm_id + } + async get(args) { args = JSON.parse(JSON.stringify(args)) if (args.deleted === undefined) args.deleted = false @@ -47,9 +87,7 @@ class PermissionRepoMySQL extends PermissionBase { , p.deleted FROM nt_perm p` - // Build WHERE manually so we can express IS NULL for group-level lookups. - // When no uid is given (gid-only query), restrict to rows where uid IS NULL - // to avoid matching per-user permission rows in the same group. + // A gid-only lookup means the group row, not a user row in that group. const dbArgs = mapToDbColumn(args, permDbMap) const conditions = [] const params = [] @@ -58,7 +96,7 @@ class PermissionRepoMySQL extends PermissionBase { params.push(val) } if (!('nt_user_id' in dbArgs) && !('nt_perm_id' in dbArgs)) { - conditions.push('p.nt_user_id IS NULL') + conditions.push('(p.nt_user_id IS NULL OR p.nt_user_id = 0)') } const query = conditions.length ? `${baseQuery} WHERE ${conditions.join(' AND ')}` : baseQuery @@ -82,7 +120,7 @@ class PermissionRepoMySQL extends PermissionBase { , p.deleted FROM nt_perm p INNER JOIN nt_user u ON p.nt_group_id = u.nt_group_id - WHERE p.nt_user_id IS NULL + WHERE (p.nt_user_id IS NULL OR p.nt_user_id = 0) AND p.deleted=${args.deleted === true ? 1 : 0} AND u.deleted=0 AND u.nt_user_id=?` @@ -96,9 +134,11 @@ class PermissionRepoMySQL extends PermissionBase { async put(args) { if (!args.id) return false const id = args.id - delete args.id + const row = partialToDb(args) + delete row.id + if (Object.keys(row).length === 0) return false const r = await Mysql.execute( - ...Mysql.update(`nt_perm`, `nt_perm_id=${id}`, mapToDbColumn(args, permDbMap)), + ...Mysql.update(`nt_perm`, `nt_perm_id=${id}`, mapToDbColumn(row, permDbMap)), ) return r.changedRows === 1 } @@ -126,35 +166,7 @@ class PermissionRepoMySQL extends PermissionBase { export default PermissionRepoMySQL function getPermFields() { - return ( - `, p.` + - [ - 'group_write', - 'group_create', - 'group_delete', - - 'zone_write', - 'zone_create', - 'zone_delegate', - 'zone_delete', - - 'zonerecord_write', - 'zonerecord_create', - 'zonerecord_delegate', - 'zonerecord_delete', - - 'user_write', - 'user_create', - 'user_delete', - - 'nameserver_write', - 'nameserver_create', - 'nameserver_delete', - - 'self_write', - 'usable_ns', - ].join(`, p.`) - ) + return `, p.${[...permissionColumns, 'self_write', 'usable_ns'].join(', p.')}` } /* the following two functions convert to and from: @@ -193,6 +205,8 @@ const boolFields = ['self_write', 'inherit', 'deleted'] function dbToObject(row) { row = JSON.parse(JSON.stringify(row)) + if (row.uid === 0) row.uid = null + if (row.gid === 0) row.gid = null for (const f of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { for (const p of ['create', 'write', 'delete', 'delegate']) { if (row[`${f}_${p}`] !== undefined) { @@ -222,6 +236,39 @@ function dbToObject(row) { return row } +/** + * Flatten the nested JSON shape to db columns for an UPDATE, touching only the + * keys the caller supplied. objectToDb() can't be reused here: it writes every + * boolean field unconditionally, which would reset fields the caller omitted. + */ +function partialToDb(args) { + const row = JSON.parse(JSON.stringify(args)) + + if (row.user?.id !== undefined) row.uid = row.user.id + if (row.group?.id !== undefined) row.gid = row.group.id + if (row.nameserver?.usable !== undefined) { + row.usable_ns = row.nameserver.usable.join(',') + } + if (Array.isArray(row.usable_ns)) row.usable_ns = row.usable_ns.join(',') + + for (const f of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { + for (const p of ['create', 'write', 'delete', 'delegate']) { + if (row[f]?.[p] === undefined) continue + row[`${f}_${p}`] = toBit(row[f][p]) + } + delete row[f] + } + for (const b of boolFields) { + if (row[b] !== undefined) row[b] = toBit(row[b]) + } + return row +} + +// callers pass either JSON booleans or the db's own 0/1 +function toBit(value) { + return value === true || value === 1 ? 1 : 0 +} + function objectToDb(row) { row = JSON.parse(JSON.stringify(row)) if (row?.user?.id !== undefined) { diff --git a/lib/permission/test/index.js b/lib/permission/test/index.js index 45dd96f..f4773bd 100644 --- a/lib/permission/test/index.js +++ b/lib/permission/test/index.js @@ -19,6 +19,13 @@ after(async () => { }) describe('permission', function () { + it('reads the seeded v2-style group permission row', async () => { + const p = await Permission.get({ gid: 1 }) + assert.ok(p) + assert.equal(p.group.id, 1) + assert.equal(p.user.id, null) + }) + it('creates a permission', async () => { assert.ok(await Permission.create(permTestCase)) }) @@ -54,6 +61,12 @@ describe('permission', function () { assert.ok(await Permission.put({ id: permTestCase.id, name: 'Test Permission' })) }) + it('reactivates a soft-deleted permission instead of duplicating it', async () => { + await Permission.delete({ id: permTestCase.id }) + assert.equal(await Permission.create(permTestCase), permTestCase.id) + assert.ok(await Permission.get({ id: permTestCase.id })) + }) + it('deletes a permission', async () => { assert.ok(await Permission.delete({ id: permTestCase.id })) let p = await Permission.get({ id: permTestCase.id, deleted: 1 }) diff --git a/lib/session/store/mysql.js b/lib/session/store/mysql.js index bc45390..012591a 100644 --- a/lib/session/store/mysql.js +++ b/lib/session/store/mysql.js @@ -13,9 +13,6 @@ class SessionRepoMySQL { } async create(args) { - const r = await this.get(args) - if (r) return r.id - const id = await Mysql.execute(...Mysql.insert(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) return id } @@ -24,6 +21,7 @@ class SessionRepoMySQL { let query = `SELECT s.nt_user_session_id AS id , s.nt_user_id AS uid , s.nt_user_session AS session + , s.last_access FROM nt_user_session s LEFT JOIN nt_user u ON s.nt_user_id = u.nt_user_id WHERE u.deleted=0` diff --git a/lib/session/test/index.js b/lib/session/test/index.js index baeed87..ff37994 100644 --- a/lib/session/test/index.js +++ b/lib/session/test/index.js @@ -34,6 +34,17 @@ describe('session', function () { }) assert.ok(sessionId) }) + + it('creates a distinct row for each login', async () => { + const secondId = await Session.create({ + nt_user_id: sessionUser.id, + session: '3.0.0', + last_access: parseInt(Date.now() / 1000, 10), + }) + assert.notEqual(secondId, sessionId) + await Session.delete({ id: secondId }) + assert.ok(await Session.get({ id: sessionId })) + }) }) describe('get', () => { diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 85b55e6..63457f1 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -72,8 +72,10 @@ class UserRepoMySQL extends UserBase { } async create(args) { - const u = await this.get({ id: args.id, gid: args.gid }) - if (u.length === 1) return u[0].id + if (args.id) { + const u = await this.get({ id: args.id }) + if (u.length === 1) return u[0].id + } args = JSON.parse(JSON.stringify(args)) @@ -90,6 +92,7 @@ class UserRepoMySQL extends UserBase { if (userId && inherit === false) { await Permission.create({ uid: userId, + gid: args.gid, inherit: false, name: `User ${args.username} perms`, }) @@ -223,6 +226,7 @@ class UserRepoMySQL extends UserBase { const [userData] = await this.get({ id }) await Permission.create({ uid: id, + gid: userData.gid, inherit: false, name: `User ${userData.username} perms`, }) diff --git a/lib/user/test/index.js b/lib/user/test/index.js index 4a25f52..5eeb8f0 100644 --- a/lib/user/test/index.js +++ b/lib/user/test/index.js @@ -53,6 +53,24 @@ describe('user', function () { let users = await User.get({ id: userCase.id }) assert.deepEqual(sanitizeActual(users[0]), sanitize(userCase)) assert.ok(users[0].permissions, 'user has permissions') + assert.equal(users[0].permissions.group.id, userCase.gid) + }) + + it('does not mistake the only user in a group for the new user', async () => { + const another = { + ...userCase, + id: undefined, + username: 'unit-test-lib-another', + email: 'unit-test-lib-another@example.com', + } + const id = await User.create(another) + try { + assert.notEqual(id, userCase.id) + const [created] = await User.get({ id }) + assert.equal(created.username, another.username) + } finally { + if (id !== userCase.id) await User.destroy({ id }) + } }) }) diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index d6a1b78..831f9ae 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -5,13 +5,18 @@ import { mapToDbColumn } from '../../util.js' const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' } const boolFields = ['deleted'] -// include_subgroups passes gid as a list of group ids; filter with IN(...). -function applyGidList(query, params, gidList) { - if (!gidList) return [query, params] +function applyAccessScope(query, params, gidScope, accessibleIds) { + if (gidScope === undefined) return [query, params] + const gidList = Array.isArray(gidScope) ? gidScope : [gidScope] const connector = /\bWHERE\b/.test(query) ? ' AND' : ' WHERE' - if (gidList.length === 0) return [`${query}${connector} nt_group_id IN (NULL)`, params] - const placeholders = gidList.map(() => '?').join(', ') - return [`${query}${connector} nt_group_id IN (${placeholders})`, [...params, ...gidList]] + const gidPlaceholders = gidList.map(() => '?').join(', ') + let clause = `nt_group_id IN (${gidPlaceholders || 'NULL'})` + const nextParams = [...params, ...gidList] + if (accessibleIds?.length) { + clause = `(${clause} OR nt_zone_id IN (${accessibleIds.map(() => '?').join(', ')}))` + nextParams.push(...accessibleIds) + } + return [`${query}${connector} ${clause}`, nextParams] } function applyZoneFilters(query, params, filters = {}) { @@ -63,8 +68,10 @@ class ZoneRepoMySQL extends ZoneBase { args = JSON.parse(JSON.stringify(args)) args.deleted = args.deleted ?? false - const gidList = Array.isArray(args.gid) ? args.gid : null - if (gidList) delete args.gid + const gidScope = args.gid + delete args.gid + const accessibleIds = args.accessible_ids + delete args.accessible_ids const filters = { search: args.search, @@ -113,7 +120,9 @@ class ZoneRepoMySQL extends ZoneBase { ) let [finalQuery, finalParams] = applyZoneFilters(query, params, filters) - ;[finalQuery, finalParams] = applyGidList(finalQuery, finalParams, gidList) + ;[finalQuery, finalParams] = applyAccessScope( + finalQuery, finalParams, gidScope, accessibleIds, + ) finalQuery += ` ORDER BY ${sortBy} ${sortDir}` const rows = await Mysql.execute(`${finalQuery}${sqlLimit}`, finalParams) @@ -151,8 +160,10 @@ class ZoneRepoMySQL extends ZoneBase { args = JSON.parse(JSON.stringify(args)) args.deleted = args.deleted ?? false - const gidList = Array.isArray(args.gid) ? args.gid : null - if (gidList) delete args.gid + const gidScope = args.gid + delete args.gid + const accessibleIds = args.accessible_ids + delete args.accessible_ids const filters = { search: args.search, @@ -170,7 +181,9 @@ class ZoneRepoMySQL extends ZoneBase { ) let [finalQuery, finalParams] = applyZoneFilters(query, params, filters) - ;[finalQuery, finalParams] = applyGidList(finalQuery, finalParams, gidList) + ;[finalQuery, finalParams] = applyAccessScope( + finalQuery, finalParams, gidScope, accessibleIds, + ) const rows = await Mysql.execute(finalQuery, finalParams) return rows?.[0]?.total ?? 0 } diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index 3b4ed24..8a59745 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -20,6 +20,17 @@ function applyZoneRecordSearch(query, params, search) { return [nextQuery, [...params, wildcard, wildcard, wildcard]] } +function applyIdScope(query, params, ids) { + if (!Array.isArray(ids)) return [query, params] + const connector = /\bWHERE\b/.test(query) ? ' AND' : ' WHERE' + if (ids.length === 0) return [`${query}${connector} 1 = 0`, params] + const placeholders = ids.map(() => '?').join(', ') + return [ + `${query}${connector} nt_zone_record_id IN (${placeholders})`, + [...params, ...ids], + ] +} + class ZoneRecordMySQL extends ZoneRecordBase { constructor() { super() @@ -50,6 +61,8 @@ class ZoneRecordMySQL extends ZoneRecordBase { const search = args.search delete args.search + const ids = args.ids + delete args.ids const hasSort = args.sort_by !== undefined || args.sort_dir !== undefined const sortBy = sortByColumn[args.sort_by] ?? 'name' @@ -82,6 +95,7 @@ class ZoneRecordMySQL extends ZoneRecordBase { ) let [finalQuery, finalParams] = applyZoneRecordSearch(query, params, search) + ;[finalQuery, finalParams] = applyIdScope(finalQuery, finalParams, ids) // Order only when sorting or paginating; the id tiebreak keeps LIMIT/OFFSET // pages stable when many records share an owner name. if (hasSort || limit !== undefined) { @@ -111,7 +125,8 @@ class ZoneRecordMySQL extends ZoneRecordBase { } const search = args.search - for (const k of ['search', 'sort_by', 'sort_dir', 'limit', 'offset']) delete args[k] + const ids = args.ids + for (const k of ['search', 'ids', 'sort_by', 'sort_dir', 'limit', 'offset']) delete args[k] const [query, params] = Mysql.select( `SELECT COUNT(*) AS total FROM nt_zone_record`, @@ -119,14 +134,27 @@ class ZoneRecordMySQL extends ZoneRecordBase { ) const [finalQuery, finalParams] = applyZoneRecordSearch(query, params, search) - const rows = await Mysql.execute(finalQuery, finalParams) + const [scopedQuery, scopedParams] = applyIdScope(finalQuery, finalParams, ids) + const rows = await Mysql.execute(scopedQuery, scopedParams) return rows?.[0]?.total ?? 0 } async put(args) { if (!args.id) return false const id = args.id + args = JSON.parse(JSON.stringify(args)) delete args.id + const current = await this.get({ id }) + if (current.length !== 1) return false + + const type = args.type ?? current[0].type + const typeChanged = args.type !== undefined && args.type !== current[0].type + args = objectToDb({ ...args, type }) + if (!typeChanged) delete args.type_id + if (typeChanged) { + args = { address: '', weight: null, priority: null, other: null, ...args } + } + const r = await Mysql.execute( ...Mysql.update(`nt_zone_record`, `nt_zone_record_id=${id}`, mapToDbColumn(args, zrDbMap)), ) diff --git a/lib/zone_record/test/index.js b/lib/zone_record/test/index.js index 5e98f3b..ae13a17 100644 --- a/lib/zone_record/test/index.js +++ b/lib/zone_record/test/index.js @@ -34,6 +34,34 @@ describe('zone_record', function () { } }) + it('changes record type without retaining stale rdata fields', async () => { + const id = 60002 + await ZoneRecord.destroy({ id }) + try { + await ZoneRecord.create({ + id, + zid: 4096, + owner: 'type-change.example.com.', + ttl: 300, + type: 'A', + address: '192.0.2.1', + }) + assert.ok(await ZoneRecord.put({ + id, + type: 'MX', + exchange: 'mail.example.com.', + preference: 10, + })) + const [updated] = await ZoneRecord.get({ id }) + assert.equal(updated.type, 'MX') + assert.equal(updated.exchange, 'mail.example.com.') + assert.equal(updated.preference, 10) + assert.equal(updated.other, undefined) + } finally { + await ZoneRecord.destroy({ id }) + } + }) + for (const rrType of fs.readdirSync('lib/zone_record/test/rrs')) { // if (rrType !== 'tlsa.json') continue describe(`${path.basename(rrType, '.json').toUpperCase()}`, function () { diff --git a/routes/authz.test.js b/routes/authz.test.js new file mode 100644 index 0000000..541f951 --- /dev/null +++ b/routes/authz.test.js @@ -0,0 +1,1040 @@ +import assert from 'node:assert/strict' +import { describe, it, before, after } from 'node:test' + +import { init } from './index.js' +import Group from '../lib/group/index.js' +import User from '../lib/user/index.js' +import Zone from '../lib/zone/index.js' +import ZoneRecord from '../lib/zone_record/index.js' +import Nameserver from '../lib/nameserver/index.js' +import Permission from '../lib/permission/index.js' +import Delegation from '../lib/delegation.js' +import Mysql from '../lib/mysql.js' + +const G_ROOT = { + id: 4200, + parent_gid: 0, + name: 'authz-root', +} +const G_CHILD = { + id: 4201, + parent_gid: 4200, + name: 'authz-child', +} +const G_OUTSIDE = { + id: 4202, + parent_gid: 0, + name: 'authz-outside', +} + +const PASSWORD = 'Wh@tA-Decent#P6ssw0rd' + +const U_FULL = { + id: 4200, + gid: 4200, + username: 'authz-full', + email: 'authz-full@example.com', + password: PASSWORD, + first_name: 'Full', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_LIMITED = { + id: 4201, + gid: 4202, + username: 'authz-limited', + email: 'authz-limited@example.com', + password: PASSWORD, + first_name: 'Limited', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_CREATED = { + id: 4211, + gid: 4201, + username: 'authz-created', + email: 'authz-created@example.com', + password: PASSWORD, + first_name: 'Created', + last_name: 'User', + inherit_group_permissions: false, +} + +const Z_INTREE = { + id: 4200, + gid: 4200, + zone: 'authz.example.com.', + mailaddr: 'hostmaster.authz.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const Z_OUTSIDE = { + id: 4201, + gid: 4202, + zone: 'authz-out.example.com.', + mailaddr: 'hostmaster.authz-out.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} + +const ZR_INTREE = { + id: 4200, + zid: 4200, + owner: 'test.authz.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const ZR_OUTSIDE = { + id: 4201, + zid: 4201, + owner: 'test.authz-out.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} + +const ZR_INTREE_OTHER = { + id: 4203, + zid: 4200, + owner: 'other.authz.example.com.', + type: 'A', + address: '192.0.2.3', + ttl: 3600, +} + +const ZR_DELEGATED_CREATE = { + id: 4210, + zid: 4201, + owner: 'created.authz-out.example.com.', + type: 'A', + address: '192.0.2.10', + ttl: 3600, +} + +const NS = { + id: 4200, + gid: 4200, + name: 'ns1.authz.example.com.', + ttl: 3600, + description: 'authz test ns', + address: '192.0.2.10', + export: { type: 'bind', interval: 0, serials: 0 }, +} + +let server +const authFull = { headers: {} } +const authLimited = { headers: {} } + +before(async () => { + // Clean up stale data from prior crashed runs + try { await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) } + catch { /* ignore */ } + try { await Delegation.delete({ gid: 4201, oid: 4200, type: 'ZONE' }) } + catch { /* ignore */ } + try { await Delegation.delete({ gid: 4201, oid: 4201, type: 'ZONE' }) } + catch { /* ignore */ } + await ZoneRecord.destroy({ id: ZR_DELEGATED_CREATE.id }) + for (const id of [4200, 4201, ZR_INTREE_OTHER.id, U_CREATED.id]) { + await ZoneRecord.destroy({ id }) + await Zone.destroy({ id }) + } + await Nameserver.destroy({ id: 4200 }) + for (const id of [4200, 4201]) { + const p = await Permission.get({ uid: id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id }) + } + for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + + for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) + for (const u of [U_FULL, U_LIMITED]) await User.create(u) + + // Full permissions for user 4200 + const fullPerm = await Permission.get({ uid: U_FULL.id }) + if (fullPerm) { + await Permission.put({ + id: fullPerm.id, + self_write: 1, + group_write: 1, group_create: 1, group_delete: 1, + zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, + zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, user_create: 1, user_delete: 1, + nameserver_write: 1, nameserver_create: 1, nameserver_delete: 1, + usable_ns: '4200', + }) + } + + // No permissions for user 4201 + const limPerm = await Permission.get({ uid: U_LIMITED.id }) + if (limPerm) { + await Permission.put({ + id: limPerm.id, + self_write: 0, + group_write: 0, group_create: 0, group_delete: 0, + zone_write: 0, zone_create: 0, zone_delete: 0, zone_delegate: 0, + zonerecord_write: 0, zonerecord_create: 0, zonerecord_delete: 0, + zonerecord_delegate: 0, + user_write: 0, user_create: 0, user_delete: 0, + nameserver_write: 0, nameserver_create: 0, nameserver_delete: 0, + usable_ns: '', + }) + } + + await Zone.create(Z_INTREE) + await Zone.create(Z_OUTSIDE) + await ZoneRecord.create(ZR_INTREE) + await ZoneRecord.create(ZR_INTREE_OTHER) + await ZoneRecord.create(ZR_OUTSIDE) + await Nameserver.create(NS) + + // Delegation: zone 4201 → group 4200, write=yes delete=no + await Delegation.create({ + gid: 4200, oid: 4201, type: 'ZONE', + perm_write: true, perm_delete: false, perm_delegate: true, + }) + + server = await init() + + // Login full-perm user + const r1 = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${U_FULL.username}@${G_ROOT.name}`, + password: PASSWORD, + }, + }) + assert.equal(r1.statusCode, 200, `full login failed: ${JSON.stringify(r1.result)}`) + authFull.headers = { + Authorization: `Bearer ${r1.result.session.token}`, + } + + // Login limited user + const r2 = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${U_LIMITED.username}@${G_OUTSIDE.name}`, + password: PASSWORD, + }, + }) + assert.equal(r2.statusCode, 200, `limited login failed: ${JSON.stringify(r2.result)}`) + authLimited.headers = { + Authorization: `Bearer ${r2.result.session.token}`, + } +}) + +after(async () => { + await server.stop() + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + await Delegation.delete({ gid: 4201, oid: 4200, type: 'ZONE' }) + await Delegation.delete({ gid: 4201, oid: 4201, type: 'ZONE' }) + await ZoneRecord.destroy({ id: ZR_DELEGATED_CREATE.id }) + await Nameserver.destroy({ id: NS.id }) + await ZoneRecord.destroy({ id: ZR_OUTSIDE.id }) + await ZoneRecord.destroy({ id: ZR_INTREE_OTHER.id }) + await ZoneRecord.destroy({ id: ZR_INTREE.id }) + await Zone.destroy({ id: Z_OUTSIDE.id }) + await Zone.destroy({ id: Z_INTREE.id }) + for (const u of [U_LIMITED, U_FULL]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + const createdPerm = await Permission.get({ uid: U_CREATED.id }) + if (createdPerm) await Permission.destroy({ id: createdPerm.id }) + await User.destroy({ id: U_CREATED.id }) + for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { + await Group.destroy({ id: g.id }) + } + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + await Mysql.disconnect() +}) + +describe('authz plugin - zone routes', () => { + it('200 for GET /zone/{id} with full-perm user (in-tree)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('200 for GET /zone/{id} with full-perm user (delegated)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for GET /zone/{id} with limited user (out of tree)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + assert.ok(res.result.error_code) + }) + + it('GET /zone defaults to the caller group', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone', + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.zone.map((z) => z.id), [Z_OUTSIDE.id]) + assert.equal(res.result.meta.pagination.total, 1) + }) + + it('403 for GET /zone scoped outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone?gid=${G_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for POST /zone when user lacks zone.create', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: authLimited.headers, + payload: { + gid: 4202, + zone: 'denied.example.com.', + mailaddr: 'hostmaster.denied.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, + }, + }) + assert.equal(res.statusCode, 403) + }) + + it('200 for PUT /zone/{id} with full-perm user', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { ttl: 7200 }, + }) + assert.equal(res.statusCode, 200) + }) + + it('does not pass unknown fields or gid changes to the zone store', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { ttl: 7201, serial: 7, gid: G_OUTSIDE.id, malicious: 'not-a-column' }, + }) + assert.equal(res.statusCode, 200) + + const [zone] = await Zone.get({ id: Z_INTREE.id }) + assert.equal(zone.gid, G_ROOT.id) + assert.equal(zone.ttl, 7201) + assert.equal(zone.serial, 7) + }) + + it('403 for POST /zone when the requested id already exists', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: authFull.headers, + payload: { ...Z_OUTSIDE, gid: G_ROOT.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /already exists/) + }) + + it('requires delete permission when PUT changes deleted state', async () => { + const perm = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: perm.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { deleted: true }, + }) + assert.equal(res.statusCode, 403) + assert.equal((await Zone.get({ id: Z_INTREE.id })).length, 1) + } finally { + await Permission.put({ id: perm.id, zone_delete: true }) + } + }) + + it('403 for DELETE /zone/{id} with delegated perm_delete=0', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) +}) + +describe('authz plugin - user self-ops', () => { + it('does not pass unknown fields, gid changes, or is_admin through self-write', async () => { + const [before] = await Mysql.execute( + 'SELECT is_admin FROM nt_user WHERE nt_user_id = ?', + [U_FULL.id], + ) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { + first_name: 'Still Full', + gid: G_OUTSIDE.id, + is_admin: true, + malicious: 'not-a-column', + }, + }) + assert.equal(res.statusCode, 200) + + const [user] = await User.get({ id: U_FULL.id }) + const [stored] = await Mysql.execute( + 'SELECT nt_group_id AS gid, is_admin FROM nt_user WHERE nt_user_id = ?', + [U_FULL.id], + ) + assert.equal(stored.gid, G_ROOT.id) + assert.equal(stored.is_admin, before.is_admin) + assert.equal(user.first_name, 'Still Full') + }) + + it('403 for DELETE /user/{self}', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to delete self/) + }) + + it('403 for PUT /user/{self} when self_write=false', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_LIMITED.id}`, + headers: authLimited.headers, + payload: { first_name: 'Nope' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to modify self/) + }) + + it('does not allow user creation to set is_admin', async () => { + const res = await server.inject({ + method: 'POST', + url: '/user', + headers: authFull.headers, + payload: { ...U_CREATED, is_admin: true }, + }) + assert.equal(res.statusCode, 201) + + const [stored] = await Mysql.execute( + 'SELECT is_admin FROM nt_user WHERE nt_user_id = ?', + [U_CREATED.id], + ) + assert.equal(stored.is_admin, null) + }) +}) + +describe('authz plugin - group self-ops', () => { + it('403 for PUT /group/{own-group}', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_ROOT.id}`, + headers: authFull.headers, + payload: { name: 'nope' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to edit your own group/) + }) + + it('403 for DELETE /group/{own-group}', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/group/${G_ROOT.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + assert.match( + res.result.error_msg, + /Not allowed to delete your own group/, + ) + }) + + it('403 when moving a group beneath itself', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_CHILD.id}`, + headers: authFull.headers, + payload: { parent_gid: G_CHILD.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /cannot contain itself/) + }) +}) + +describe('authz plugin - zone record delegation', () => { + it('200 for GET /zone_record/{id} via pseudo-delegation', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone_record/${ZR_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for GET /zone_record/{id} with limited user', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone_record/${ZR_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for an unscoped zone record collection', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone_record', + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 when moving an in-tree record into a delegated zone without add permission', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE.id}`, + headers: authFull.headers, + payload: { zid: Z_OUTSIDE.id }, + }) + assert.equal(res.statusCode, 403) + + const [record] = await ZoneRecord.get({ id: ZR_INTREE.id }) + assert.equal(record.zid, Z_INTREE.id) + }) + + it('does not require create permission when an edit repeats the current zone id', async () => { + const perm = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: perm.id, zonerecord_create: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE.id}`, + headers: authFull.headers, + payload: { zid: Z_INTREE.id, ttl: 3601 }, + }) + assert.equal(res.statusCode, 200) + } finally { + await Permission.put({ id: perm.id, zonerecord_create: true }) + await ZoneRecord.put({ id: ZR_INTREE.id, ttl: ZR_INTREE.ttl }) + } + }) + + it('enforces add/delete-record flags on a delegated zone', async () => { + let res = await server.inject({ + method: 'POST', + url: '/zone_record', + headers: authFull.headers, + payload: ZR_DELEGATED_CREATE, + }) + assert.equal(res.statusCode, 403) + + await Delegation.put({ + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + zone_perm_add_records: true, + zone_perm_delete_records: true, + }) + + res = await server.inject({ + method: 'POST', + url: '/zone_record', + headers: authFull.headers, + payload: ZR_DELEGATED_CREATE, + }) + assert.equal(res.statusCode, 201) + + res = await server.inject({ + method: 'DELETE', + url: `/zone_record/${ZR_DELEGATED_CREATE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + + await Delegation.put({ + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + zone_perm_add_records: false, + zone_perm_delete_records: false, + }) + }) +}) + +describe('authz plugin - delegation routes', () => { + it('creates a fail-closed delegation and records the authenticated actor', async () => { + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: { + gid: G_CHILD.id, + oid: Z_INTREE.id, + type: 'ZONE', + delegated_by_id: U_LIMITED.id, + delegated_by_name: U_LIMITED.username, + }, + }) + assert.equal(res.statusCode, 201) + assert.equal(res.result.delegation.length, 1) + assert.equal(res.result.delegation[0].delegate_write, 0) + assert.equal(res.result.delegation[0].delegate_delete, 0) + assert.equal(res.result.delegation[0].delegate_delegate, 0) + assert.equal(res.result.delegation[0].delegated_by_id, U_FULL.id) + assert.equal(res.result.delegation[0].delegated_by_name, U_FULL.username) + }) + + it('GET with gid and oid returns only that delegation', async () => { + const res = await server.inject({ + method: 'GET', + url: `/delegation?gid=${G_CHILD.id}&oid=${Z_INTREE.id}&type=ZONE`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.delegation.length, 1) + assert.equal(res.result.delegation[0].nt_group_id, G_CHILD.id) + }) + + it('cannot delegate an object back to your own group', async () => { + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: { gid: G_ROOT.id, oid: Z_INTREE.id, type: 'ZONE' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own group/) + }) + + it('caps a re-delegation at the permissions on its source delegation', async () => { + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: { + gid: G_CHILD.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + perm_write: true, + perm_delete: true, + perm_delegate: true, + zone_perm_add_records: true, + zone_perm_delete_records: true, + }, + }) + assert.equal(res.statusCode, 201) + const [delegation] = res.result.delegation + assert.equal(delegation.delegate_write, 1) + assert.equal(delegation.delegate_delete, 1) + assert.equal(delegation.delegate_delegate, 1) + assert.equal(delegation.delegate_add_records, 0) + assert.equal(delegation.delegate_delete_records, 0) + }) + + it('cannot edit a delegation when the source object is itself delegated', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/delegation', + headers: authFull.headers, + payload: { + gid: G_CHILD.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + perm_write: false, + }, + }) + assert.equal(res.statusCode, 403) + }) + + it('perm_delete permits removal, never deletion of the delegated zone', async () => { + await Delegation.put({ + gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', perm_delete: true, + }) + const res = await server.inject({ + method: 'DELETE', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + await Delegation.put({ + gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', perm_delete: false, + }) + }) +}) + +describe('authz plugin - create target resolution', () => { + const G_PLANTED = 4212 + + after(async () => { + await Group.destroy({ id: G_PLANTED }) + await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id = ?', [G_PLANTED]) + }) + + it('authorizes the group a new group is actually filed under', async () => { + // gid is not the key Group.create reads; authorizing it would let + // parent_gid point anywhere + const res = await server.inject({ + method: 'POST', + url: '/group', + headers: authFull.headers, + payload: { + id: G_PLANTED, + name: 'authz-planted', + gid: G_ROOT.id, + parent_gid: G_OUTSIDE.id, + }, + }) + assert.equal(res.statusCode, 403) + assert.equal((await Group.get({ id: G_PLANTED })).length, 0) + }) + + it('403 for POST /group with no parent group', async () => { + const res = await server.inject({ + method: 'POST', + url: '/group', + headers: authFull.headers, + payload: { id: G_PLANTED, name: 'authz-rootless', gid: G_ROOT.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /No target group/) + assert.equal((await Group.get({ id: G_PLANTED })).length, 0) + }) + + it('201 for POST /group inside the caller tree', async () => { + const res = await server.inject({ + method: 'POST', + url: '/group', + headers: authFull.headers, + payload: { id: G_PLANTED, name: 'authz-planted', parent_gid: G_ROOT.id }, + }) + assert.equal(res.statusCode, 201) + const [created] = await Group.get({ id: G_PLANTED }) + assert.equal(created.parent_gid, G_ROOT.id) + }) +}) + +describe('authz plugin - nameserver reads', () => { + const NS_CHILD = { + id: 4201, + gid: G_CHILD.id, + name: 'ns2.authz.example.com.', + ttl: 3600, + address: '192.0.2.11', + export: { type: 'bind', interval: 0, serials: 0 }, + } + + before(async () => { + await Nameserver.destroy({ id: NS_CHILD.id }) + await Nameserver.create(NS_CHILD) + }) + + after(async () => { + await Nameserver.destroy({ id: NS_CHILD.id }) + }) + + it('returns a subgroup nameserver fetched by id', async () => { + const res = await server.inject({ + method: 'GET', + url: `/nameserver/${NS_CHILD.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.nameserver.length, 1) + assert.equal(res.result.nameserver[0].id, NS_CHILD.id) + }) + + it('returns an active nameserver outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/nameserver/${NS_CHILD.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.nameserver[0].id, NS_CHILD.id) + }) + + it('still scopes an unqualified collection to the caller group', async () => { + const res = await server.inject({ + method: 'GET', + url: '/nameserver', + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.ok(res.result.nameserver.every((n) => n.gid === G_ROOT.id)) + }) +}) + +describe('authz plugin - permission records', () => { + it('PUT /permission/{id} stores the permissions it was given', async () => { + const perm = await Permission.get({ gid: G_CHILD.id }) + assert.ok(perm, 'the child group has a permission row') + + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { zone: { create: true, write: true }, self_write: true }, + }) + assert.equal(res.statusCode, 200) + + const after = await Permission.get({ id: perm.id }) + assert.equal(after.zone.create, true) + assert.equal(after.zone.write, true) + assert.equal(after.self_write, true) + // untouched fields survive a partial update + assert.equal(after.gid ?? after.group.id, G_CHILD.id) + + await Permission.put({ + id: perm.id, zone_create: 0, zone_write: 0, self_write: 0, + }) + }) + + // an in-tree target, so the only thing that can deny is the gid mismatch + const U_TARGET = { + id: 4213, + gid: G_CHILD.id, + username: 'authz-permtarget', + email: 'authz-permtarget@example.com', + password: PASSWORD, + first_name: 'Perm', + last_name: 'Target', + inherit_group_permissions: true, + } + + // direct SQL: Permission.get throws when a crashed run left two rows behind + const clearTarget = () => + Mysql.execute('DELETE FROM nt_perm WHERE nt_user_id = ?', [U_TARGET.id]) + + before(async () => { + await clearTarget() + await User.destroy({ id: U_TARGET.id }) + await User.create(U_TARGET) + }) + + after(async () => { + await clearTarget() + await User.destroy({ id: U_TARGET.id }) + }) + + it('403 for a permission whose user and group disagree', async () => { + const res = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { + name: 'mismatched', + user: { id: U_TARGET.id }, + group: { id: G_ROOT.id }, + }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /does not belong to that group/) + }) + + it('201 for a permission naming the target user own group', async () => { + const res = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { + name: 'matched', + user: { id: U_TARGET.id }, + group: { id: G_CHILD.id }, + }, + }) + assert.equal(res.statusCode, 201) + }) + + it('does not grant a permission by switching another user to inheritance', async () => { + const actorPerm = await Permission.get({ uid: U_FULL.id }) + const groupPerm = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: actorPerm.id, zone_delete: false }) + await Permission.put({ id: groupPerm.id, zone_delete: true }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_TARGET.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: true }, + }) + assert.equal(res.statusCode, 200) + + const explicit = await Permission.get({ uid: U_TARGET.id }) + assert.ok(explicit) + assert.equal((await Permission.getEffective(U_TARGET.id)).zone.delete, false) + } finally { + await Permission.put({ id: actorPerm.id, zone_delete: true }) + await Permission.put({ id: groupPerm.id, zone_delete: false }) + } + }) + + it('does not revoke unmanaged permissions when creating an explicit row', async () => { + const actorPerm = await Permission.get({ uid: U_FULL.id }) + const groupPerm = await Permission.get({ gid: G_CHILD.id }) + const explicit = await Permission.get({ uid: U_TARGET.id }) + if (explicit) await Permission.destroy({ id: explicit.id }) + await Permission.put({ id: actorPerm.id, zone_delete: false }) + await Permission.put({ id: groupPerm.id, zone_delete: true }) + try { + const res = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { + name: 'preserved', + inherit: false, + user: { id: U_TARGET.id }, + }, + }) + assert.equal(res.statusCode, 201) + assert.equal((await Permission.getEffective(U_TARGET.id)).zone.delete, true) + } finally { + await Permission.put({ id: actorPerm.id, zone_delete: true }) + await Permission.put({ id: groupPerm.id, zone_delete: false }) + } + }) +}) + +describe('authz plugin - delegation type and pseudo access', () => { + it('refuses to delegate an object type with no permission cap', async () => { + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: { + gid: G_CHILD.id, + oid: NS.id, + type: 'NAMESERVER', + perm_write: true, + }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /cannot be delegated/) + }) + + it('grants read on a zone holding a record delegated to the caller', async () => { + // limited user's group has no access to zone 4200, only to one record in it + await Delegation.create({ + gid: G_OUTSIDE.id, oid: ZR_INTREE.id, type: 'ZONERECORD', + perm_write: false, perm_delete: false, perm_delegate: false, + }) + try { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 200) + + const zones = await server.inject({ + method: 'GET', + url: '/zone', + headers: authLimited.headers, + }) + assert.equal(zones.statusCode, 200) + assert.deepEqual( + zones.result.zone.map((zone) => zone.id).sort((a, b) => a - b), + [Z_INTREE.id, Z_OUTSIDE.id], + ) + + const records = await server.inject({ + method: 'GET', + url: `/zone_record?zid=${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(records.statusCode, 200) + assert.deepEqual(records.result.zone_record.map((record) => record.id), [ZR_INTREE.id]) + assert.equal(records.result.meta.pagination.total, 1) + + const write = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authLimited.headers, + payload: { ttl: 7200 }, + }) + assert.equal(write.statusCode, 403) + } finally { + await Delegation.delete({ + gid: G_OUTSIDE.id, oid: ZR_INTREE.id, type: 'ZONERECORD', + }) + } + }) +}) + +describe('authz plugin - deleted-state transitions', () => { + it('does not require delete permission when deleted is unchanged', async () => { + const perm = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: perm.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { deleted: false, ttl: 3600 }, + }) + assert.equal(res.statusCode, 200) + } finally { + await Permission.put({ id: perm.id, zone_delete: true }) + } + }) +}) + +describe('authz plugin - self permission inheritance', () => { + it('ignores inherit_group_permissions on a self edit', async () => { + const before = await Permission.get({ uid: U_FULL.id }) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: true }, + }) + assert.equal(res.statusCode, 200) + + const after = await Permission.get({ uid: U_FULL.id }) + assert.ok(after, 'the explicit permission row survives') + assert.equal(after.id, before.id) + }) +}) diff --git a/routes/delegation.js b/routes/delegation.js new file mode 100644 index 0000000..2a6c181 --- /dev/null +++ b/routes/delegation.js @@ -0,0 +1,248 @@ +import validate from '@nictool/validate' + +import Authz from '../lib/authz.js' +import Delegation from '../lib/delegation.js' +import Permission from '../lib/permission/index.js' +import { meta } from '../lib/util.js' + +const DELEGABLE_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', +} + +const DELEG_PERM_CAP = { + ZONE: { + perm_write: ['zone', 'write'], + perm_delegate: ['zone', 'delegate'], + zone_perm_add_records: ['zonerecord', 'create'], + zone_perm_delete_records: ['zonerecord', 'delete'], + }, + ZONERECORD: { + perm_write: ['zonerecord', 'write'], + perm_delegate: ['zonerecord', 'delegate'], + }, +} + +function capDelegationPerms(payload, perm, sourceDelegation, mode) { + const capMap = DELEG_PERM_CAP[payload.type] + if (!capMap) return + for (const [field, [resource, action]] of Object.entries(capMap)) { + if (payload[field] === undefined) continue + if ( + perm[resource]?.[action] !== true + || (sourceDelegation && sourceDelegation[field] !== 1) + ) { + if (mode === 'create') payload[field] = false + else delete payload[field] + } + } +} + +function DelegationRoutes(server) { + server.route([ + { + method: 'GET', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'readDelegation' } }, + validate: { + query: validate.delegation.GET_req, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const getArgs = {} + if (request.query.gid !== undefined) getArgs.gid = request.query.gid + if (request.query.oid !== undefined) getArgs.oid = request.query.oid + if (request.query.type !== undefined) getArgs.type = request.query.type + + const delegation = await Delegation.get(getArgs) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `here are your delegations`, + }, + }) + .code(200) + }, + }, + { + method: 'POST', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'payload.oid' } }, + validate: { + payload: validate.delegation.POST, + options: { noDefaults: true }, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const { user } = request.auth.credentials + const perm = await Permission.getEffective(user.id) + const sourceDelegation = await sourceDelegationFor(request) + capDelegationPerms(request.payload, perm, sourceDelegation, 'create') + setActor(request) + + const result = await Delegation.create(request.payload) + + if (result.duplicate) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `that delegation already exists`, + }, + }) + .code(409) + } + + const delegation = await Delegation.get({ + gid: request.payload.gid, + oid: request.payload.oid, + type: request.payload.type, + }) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `the delegation was created`, + }, + }) + .code(201) + }, + }, + { + method: 'PUT', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'editDelegation', idFrom: 'payload.oid' } }, + validate: { + payload: validate.delegation.PUT, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const { user } = request.auth.credentials + const perm = await Permission.getEffective(user.id) + capDelegationPerms(request.payload, perm, null, 'edit') + setActor(request) + + const result = await Delegation.put(request.payload) + + if (result === null) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I couldn't find that delegation`, + }, + }) + .code(404) + } + + const delegation = await Delegation.get({ + gid: request.payload.gid, + oid: request.payload.oid, + type: request.payload.type, + }) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `the delegation was updated`, + }, + }) + .code(200) + }, + }, + { + method: 'DELETE', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'deleteDelegation', idFrom: 'query.oid' } }, + validate: { + query: validate.delegation.DELETE, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const args = { + gid: request.query.gid, + oid: request.query.oid, + type: request.query.type, + delegated_by_id: request.auth.credentials.user.id, + delegated_by_name: request.auth.credentials.user.username, + } + + const result = await Delegation.delete(args) + + if (result === null) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I couldn't find that delegation`, + }, + }) + .code(404) + } + + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I deleted that delegation`, + }, + }) + .code(200) + }, + }, + ]) +} + +function setActor(request) { + request.payload.delegated_by_id = request.auth.credentials.user.id + request.payload.delegated_by_name = request.auth.credentials.user.username +} + +async function sourceDelegationFor(request) { + const resource = DELEGABLE_RESOURCE[request.payload.type] + if (!resource) return null + const gid = await Authz.getObjectGroupId(resource, request.payload.oid) + if (gid !== null && await Authz.isInGroupTree(request.auth.credentials.group.id, gid)) { + return null + } + return Authz.getDelegateAccess( + request.auth.credentials.group.id, + request.payload.oid, + resource, + ) +} + +export default DelegationRoutes + +export { Delegation, DelegationRoutes } diff --git a/routes/group.js b/routes/group.js index d835f40..4450d18 100644 --- a/routes/group.js +++ b/routes/group.js @@ -3,14 +3,55 @@ import validate from '@nictool/validate' import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' +import Authz from '../lib/authz.js' +import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' +const PERM_FIELDS = new Set([ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', + 'self_write', 'usable_ns', +]) + +const GROUP_POST_FIELDS = new Set([ + 'id', 'name', 'parent_gid', 'deleted', 'usable_ns', +]) + +const GROUP_PUT_FIELDS = new Set([ + 'name', 'parent_gid', 'deleted', 'usable_ns', +]) + +function extractPermFields(payload) { + const permFields = {} + for (const key of Object.keys(payload)) { + if (PERM_FIELDS.has(key)) { + permFields[key] = payload[key] + delete payload[key] + } + } + return permFields +} + +function pickFields(payload, fields) { + return Object.fromEntries(Object.entries(payload).filter(([key]) => fields.has(key))) +} + function GroupRoutes(server) { server.route([ { method: 'GET', path: '/group', options: { + app: { + permission: { + resource: 'group', + action: 'read', + list: { resource: 'group', idFrom: 'query.parent_gid', defaultToGroup: true }, + }, + }, validate: { query: validate.group.GET_list_req, }, @@ -24,7 +65,7 @@ function GroupRoutes(server) { deleted: request.query.deleted === true ? 1 : 0, include_subgroups: request.query.include_subgroups === true, } - if (request.query.parent_gid !== undefined) getArgs.parent_gid = request.query.parent_gid + getArgs.parent_gid = request.query.parent_gid ?? request.auth.credentials.group.id if (request.query.name !== undefined) getArgs.name = request.query.name const groups = await Group.get(getArgs) @@ -36,20 +77,25 @@ function GroupRoutes(server) { method: 'GET', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.group.GET_req, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { - const groups = await Group.get({ - deleted: request.query.deleted ?? 0, + const getArgs = { id: parseInt(request.params.id, 10), include_subgroups: request.query.include_subgroups === true, - }) + } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } + const groups = await Group.get(getArgs) // Return an array like the other object types (zone/nameserver/user/ // zone_record) rather than a bare object, for a consistent API contract. @@ -68,16 +114,29 @@ function GroupRoutes(server) { method: 'POST', path: '/group', options: { + app: { permission: { resource: 'group', action: 'create' } }, validate: { payload: validate.group.POST, + options: { allowUnknown: true }, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { - const gid = await Group.create(request.payload) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + const gid = await Group.create(pickFields(request.payload, GROUP_POST_FIELDS)) + + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ gid }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } const groups = await Group.get({ id: gid }) @@ -96,17 +155,37 @@ function GroupRoutes(server) { method: 'PUT', path: '/group/{id}', options: { + app: { + permission: { + resource: 'group', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.parent_gid', + }, + }, validate: { payload: validate.group.PUT, + options: { allowUnknown: true }, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) - await Group.put({ ...request.payload, id }) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + const existingPerm = await Permission.get({ gid: id }) + request.payload = Authz.capPermissions(userPerm, request.payload, existingPerm) + + const permFields = extractPermFields(request.payload) + if (Object.keys(permFields).length > 0) { + if (existingPerm) await Permission.put({ id: existingPerm.id, ...permFields }) + } + + await Group.put({ ...pickFields(request.payload, GROUP_PUT_FIELDS), id }) const groups = await Group.get({ id }) @@ -125,6 +204,7 @@ function GroupRoutes(server) { method: 'DELETE', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.group.DELETE, }, diff --git a/routes/group.test.js b/routes/group.test.js index 8b83199..33580d8 100644 --- a/routes/group.test.js +++ b/routes/group.test.js @@ -6,6 +6,7 @@ import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } let server @@ -15,6 +16,7 @@ before(async () => { server = await init() await Group.create(groupCase) await User.create(userCase) + await grantGroupPermissions(groupCase.id) }) after(async () => { @@ -53,6 +55,9 @@ describe('group routes', () => { const testCase = JSON.parse(JSON.stringify(groupCase)) testCase.id = case2Id // make it unique testCase.name = `example2.com` + // create it inside the fixture user's group tree, like a real user would; + // a top-level group is only visible to the root admin + testCase.parent_gid = groupCase.id delete testCase.deleted const res = await server.inject({ diff --git a/routes/index.js b/routes/index.js index 598f092..399b2dc 100644 --- a/routes/index.js +++ b/routes/index.js @@ -24,6 +24,8 @@ import { PermissionRoutes } from './permission.js' import { NameserverRoutes } from './nameserver.js' import { ZoneRoutes } from './zone.js' import { ZoneRecordRoutes } from './zone_record.js' +import { DelegationRoutes } from './delegation.js' +import authzPlugin from '../lib/authz-plugin.js' let server @@ -105,6 +107,8 @@ async function setup() { server.auth.default('nt_jwt_strategy') + await server.register(authzPlugin) + server.route({ method: 'GET', path: '/', @@ -120,6 +124,7 @@ async function setup() { NameserverRoutes(server) ZoneRoutes(server) ZoneRecordRoutes(server) + DelegationRoutes(server) server.route({ method: '*', diff --git a/routes/nameserver.js b/routes/nameserver.js index 3fb4ea1..aea0647 100644 --- a/routes/nameserver.js +++ b/routes/nameserver.js @@ -9,6 +9,14 @@ function NameserverRoutes(server) { method: 'GET', path: '/nameserver/{id?}', options: { + app: { + permission: { + resource: 'nameserver', + action: 'read', + idFrom: 'params.id', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.nameserver.GET_req, }, @@ -18,11 +26,18 @@ function NameserverRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const getArgs = { - deleted: request.query.deleted === true ? 1 : 0, + const getArgs = {} + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) - if (request.query.gid) getArgs.gid = parseInt(request.query.gid, 10) + // authz has already scoped a single-object fetch, which may resolve + // through a usable_ns grant on a nameserver outside the caller's group + if (request.query.gid !== undefined) { + getArgs.gid = parseInt(request.query.gid, 10) + } else if (!request.params.id) { + getArgs.gid = request.auth.credentials.group.id + } const nameservers = await Nameserver.get(getArgs) @@ -41,6 +56,7 @@ function NameserverRoutes(server) { method: 'POST', path: '/nameserver', options: { + app: { permission: { resource: 'nameserver', action: 'create' } }, validate: { payload: validate.nameserver.POST, }, @@ -69,6 +85,7 @@ function NameserverRoutes(server) { method: 'PUT', path: '/nameserver/{id}', options: { + app: { permission: { resource: 'nameserver', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.nameserver.PUT, }, @@ -98,6 +115,7 @@ function NameserverRoutes(server) { method: 'DELETE', path: '/nameserver/{id}', options: { + app: { permission: { resource: 'nameserver', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.nameserver.DELETE, }, diff --git a/routes/nameserver.test.js b/routes/nameserver.test.js index ef80f95..7811113 100644 --- a/routes/nameserver.test.js +++ b/routes/nameserver.test.js @@ -7,6 +7,7 @@ import User from '../lib/user/index.js' import Nameserver from '../lib/nameserver/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/nameserver.json' with { type: 'json' } @@ -17,6 +18,7 @@ before(async () => { await Nameserver.destroy({ id: case2Id }) await Group.create(groupCase) await User.create(userCase) + await grantGroupPermissions(groupCase.id) await Nameserver.create(nsCase) server = await init() }) diff --git a/routes/permission.js b/routes/permission.js index b06f537..04e28f9 100644 --- a/routes/permission.js +++ b/routes/permission.js @@ -1,5 +1,6 @@ import validate from '@nictool/validate' +import Authz from '../lib/authz.js' import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' @@ -9,6 +10,7 @@ function PermissionRoutes(server) { method: 'GET', path: '/permission/{id}', options: { + app: { permission: { resource: 'permission', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.permission.GET_req, }, @@ -40,6 +42,7 @@ function PermissionRoutes(server) { method: 'POST', path: '/permission', options: { + app: { permission: { resource: 'permission', action: 'create' } }, validate: { payload: validate.permission.POST, }, @@ -49,6 +52,20 @@ function PermissionRoutes(server) { tags: ['api'], }, handler: async (request, h) => { + const userPerm = await Permission.getEffective(request.auth.credentials.user.id) + const uid = request.payload.user?.id + if (uid !== undefined && request.payload.group?.id == null) { + const gid = await Authz.getObjectGroupId('user', uid) + request.payload.group = { ...request.payload.group, id: gid } + } + const currentPerm = uid === undefined ? null : await Permission.getEffective(uid) + request.payload = Authz.capPermissions(userPerm, request.payload, currentPerm) + if (uid !== undefined && request.payload.inherit !== true) { + request.payload = Authz.preserveUnmanagedPermissions( + userPerm, request.payload, currentPerm, + ) + } + delete request.payload.id const pid = await Permission.create(request.payload) const permission = await Permission.get({ id: pid }) @@ -64,10 +81,63 @@ function PermissionRoutes(server) { .code(201) }, }, + { + method: 'PUT', + path: '/permission/{id}', + options: { + app: { permission: { resource: 'permission', action: 'write', idFrom: 'params.id' } }, + validate: { + payload: validate.permission.POST, + }, + response: { + schema: validate.permission.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const id = parseInt(request.params.id, 10) + const existing = await Permission.get({ id }) + if (!existing) { + return h + .response({ meta: { api: meta.api, msg: `permission not found` } }) + .code(404) + } + + const userPerm = await Permission.getEffective(request.auth.credentials.user.id) + const payload = Authz.capPermissions(userPerm, request.payload, existing) + if (payload.inherit !== undefined) { + const uid = existing.user?.id + if (uid === undefined || uid === null) { + delete payload.inherit + } else { + const gid = await Authz.getObjectGroupId('user', uid) + const groupPerm = gid === null ? null : await Permission.get({ gid }) + const before = existing.inherit === false ? existing : groupPerm + const after = payload.inherit ? groupPerm : existing + if (!Authz.canTransitionPermissions(userPerm, before, after)) { + delete payload.inherit + } + } + } + delete payload.id + delete payload.user + delete payload.group + await Permission.put({ ...payload, id }) + const permission = await Permission.get({ id }) + + return h + .response({ + permission, + meta: { api: meta.api, msg: `permission updated` }, + }) + .code(200) + }, + }, { method: 'DELETE', path: '/permission/{id}', options: { + app: { permission: { resource: 'permission', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.permission.DELETE, failAction: 'log', diff --git a/routes/permission.test.js b/routes/permission.test.js index 18089f4..73077fa 100644 --- a/routes/permission.test.js +++ b/routes/permission.test.js @@ -52,14 +52,8 @@ describe('permission routes', () => { assert.equal(res.result.permission.nameserver.create, false) }) - it(`POST /permission (${case2Id})`, async () => { + it('POST /permission cannot create your own permissions', async () => { const testCase = JSON.parse(JSON.stringify(permCase)) - testCase.id = case2Id // make it unique - testCase.user.id = case2Id - testCase.group.id = case2Id - testCase.name = `Route Test Permission 2` - delete testCase.deleted - // console.log(testCase) const res = await server.inject({ method: 'POST', @@ -67,64 +61,29 @@ describe('permission routes', () => { headers: auth.headers, payload: testCase, }) - // console.log(res.result) - assert.equal(res.statusCode, 201) - assert.equal(res.result.permission.zone.create, true) - assert.equal(res.result.permission.nameserver.create, false) - }) - - it(`GET /permission/${case2Id}`, async () => { - const res = await server.inject({ - method: 'GET', - url: `/permission/${case2Id}`, - headers: auth.headers, - }) - // console.log(res.result) - assert.equal(res.statusCode, 200) - assert.equal(res.result.permission.zone.create, true) - assert.equal(res.result.permission.nameserver.create, false) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own permissions/) }) - it(`DELETE /permission/${case2Id}`, async () => { + it(`PUT /permission/${userCase.id} cannot change your own permissions`, async () => { const res = await server.inject({ - method: 'DELETE', - url: `/permission/${case2Id}`, + method: 'PUT', + url: `/permission/${userCase.id}`, headers: auth.headers, + payload: permCase, }) - // console.log(res.result) - assert.equal(res.statusCode, 200) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own permissions/) }) - it(`DELETE /permission/${case2Id}`, async () => { + it(`DELETE /permission/${userCase.id} cannot delete your own permissions`, async () => { const res = await server.inject({ method: 'DELETE', - url: `/permission/${case2Id}`, - headers: auth.headers, - }) - // console.log(res.result) - assert.equal(res.statusCode, 404) - }) - - it(`GET /permission/${case2Id}`, async () => { - const res = await server.inject({ - method: 'GET', - url: `/permission/${case2Id}`, - headers: auth.headers, - }) - // console.log(res.result) - // assert.equal(res.statusCode, 200) - assert.equal(res.result.permission, undefined) - }) - - it(`GET /permission/${case2Id} (deleted)`, async () => { - const res = await server.inject({ - method: 'GET', - url: `/permission/${case2Id}?deleted=true`, + url: `/permission/${userCase.id}`, headers: auth.headers, }) - // console.log(res.result) - assert.equal(res.statusCode, 200) - assert.ok(res.result.permission) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own permissions/) }) it('DELETE /session', async () => { diff --git a/routes/session.js b/routes/session.js index e49ee4e..1c06c12 100644 --- a/routes/session.js +++ b/routes/session.js @@ -5,6 +5,7 @@ import Jwt from '@hapi/jwt' import User from '../lib/user/index.js' import Session from '../lib/session/index.js' +import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' @@ -18,6 +19,7 @@ function SessionRoutes(server) { options: { response: { schema: validate.session.GET_res, + options: { allowUnknown: true }, }, tags: ['api'], }, @@ -26,11 +28,20 @@ function SessionRoutes(server) { Session.put({ id: session.id, last_access: true }) + const perm = await Permission.getEffective(user.id) + const groupPerm = await Permission.getGroup({ + uid: user.id, deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: user, group: group, session: { id: session.id }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `working on it`, @@ -49,6 +60,7 @@ function SessionRoutes(server) { }, response: { schema: validate.session.GET_res, + options: { allowUnknown: true }, }, tags: ['api'], }, @@ -83,11 +95,20 @@ function SessionRoutes(server) { }, ) + const perm = await Permission.getEffective(account.user.id) + const groupPerm = await Permission.getGroup({ + uid: account.user.id, deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: account.user, group: account.group, session: { id: sessId, token: token }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `you are logged in`, diff --git a/routes/session.test.js b/routes/session.test.js index a49f1ee..019dfe3 100644 --- a/routes/session.test.js +++ b/routes/session.test.js @@ -66,9 +66,16 @@ describe('session routes', () => { }) // console.log(res.result) assert.equal(res.statusCode, 200) + + const revoked = await server.inject({ + method: 'GET', + url: '/session', + headers: auth.headers, + }) + assert.equal(revoked.statusCode, 401) }) - const routes = [{ GET: '/' }, { GET: '/session' }, { DELETE: '/session' }] + const routes = [{ GET: '/' }, { GET: '/session' }] for (const r of routes) { const key = Object.keys(r)[0] diff --git a/routes/test/permissions.js b/routes/test/permissions.js new file mode 100644 index 0000000..4611a13 --- /dev/null +++ b/routes/test/permissions.js @@ -0,0 +1,38 @@ +import Permission from '../../lib/permission/index.js' + +// Route tests exercise write ops as fixture users, who inherit group-level +// permissions; grant everything so the authz plugin lets them through. +export async function grantGroupPermissions(gid) { + const perms = { + group_write: 1, + group_create: 1, + group_delete: 1, + zone_write: 1, + zone_create: 1, + zone_delegate: 1, + zone_delete: 1, + zonerecord_write: 1, + zonerecord_create: 1, + zonerecord_delegate: 1, + zonerecord_delete: 1, + user_write: 1, + user_create: 1, + user_delete: 1, + nameserver_write: 1, + nameserver_create: 1, + nameserver_delete: 1, + } + + const existing = await Permission.get({ gid }) + if (existing) { + await Permission.put({ id: existing.id, ...perms }) + return + } + + await Permission.create({ + gid, + inherit: true, + name: `route test permissions`, + ...perms, + }) +} diff --git a/routes/user.js b/routes/user.js index eeef463..1bfc531 100644 --- a/routes/user.js +++ b/routes/user.js @@ -2,14 +2,66 @@ import validate from '@nictool/validate' import User from '../lib/user/index.js' import Credentials from '../lib/user/credentials.js' +import Authz from '../lib/authz.js' +import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' +const PERM_FIELDS = new Set([ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', + 'self_write', 'usable_ns', +]) + +const USER_POST_FIELDS = new Set([ + 'id', 'gid', 'first_name', 'last_name', 'username', 'email', 'password', + 'inherit_group_permissions', +]) + +const USER_PUT_FIELDS = new Set([ + 'first_name', 'last_name', 'username', 'email', 'password', + 'deleted', 'inherit_group_permissions', +]) + +function extractPermFields(payload) { + const permFields = {} + for (const key of Object.keys(payload)) { + if (PERM_FIELDS.has(key)) { + permFields[key] = payload[key] + delete payload[key] + } + } + return permFields +} + +function pickFields(payload, fields) { + return Object.fromEntries(Object.entries(payload).filter(([key]) => fields.has(key))) +} + +function prepareUserResponse(user) { + const gid = parseInt(user.gid, 10) + delete user.gid + if (user.permissions?.group && user.permissions.group.id == null) { + delete user.permissions.group.id + } + return gid +} + function UserRoutes(server) { server.route([ { method: 'GET', path: '/user', options: { + app: { + permission: { + resource: 'user', + action: 'read', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.user.GET_req, }, @@ -28,7 +80,7 @@ function UserRoutes(server) { } const users = await User.get(getArgs) - for (const u of users) delete u.gid + for (const u of users) prepareUserResponse(u) return h .response({ @@ -45,6 +97,7 @@ function UserRoutes(server) { method: 'GET', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.user.GET_req, }, @@ -54,10 +107,11 @@ function UserRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const users = await User.get({ - deleted: request.query.deleted ?? 0, - id: parseInt(request.params.id, 10), - }) + const getArgs = { id: parseInt(request.params.id, 10) } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } + const users = await User.get(getArgs) if (users.length !== 1) { return h @@ -70,8 +124,13 @@ function UserRoutes(server) { .code(204) } - const gid = parseInt(users[0].gid, 10) - delete users[0].gid + const gid = prepareUserResponse(users[0]) + const groupPerm = await Permission.getGroup({ + uid: getArgs.id, deleted: false, + }) + if (users[0].permissions && groupPerm) { + users[0].permissions.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } return h .response({ @@ -89,8 +148,10 @@ function UserRoutes(server) { method: 'POST', path: '/user', options: { + app: { permission: { resource: 'user', action: 'create' } }, validate: { payload: validate.user.POST, + options: { allowUnknown: true }, }, response: { schema: validate.user.GET_res, @@ -98,14 +159,20 @@ function UserRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const uid = await User.create(request.payload) - if (!uid) { - console.log(`POST /user oops`) // TODO + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + const uid = await User.create(pickFields(request.payload, USER_POST_FIELDS)) + + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ uid }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) } const users = await User.get({ id: uid }) - const group = { id: users[0].gid } - delete users[0].gid + const group = { id: prepareUserResponse(users[0]) } return h .response({ @@ -123,8 +190,10 @@ function UserRoutes(server) { method: 'PUT', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.user.PUT, + options: { allowUnknown: true }, }, response: { schema: validate.user.GET_res, @@ -133,6 +202,43 @@ function UserRoutes(server) { }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + const existingPerm = await Permission.get({ uid: id }) + const gid = await Authz.getObjectGroupId('user', id) + const groupPerm = gid === null ? null : await Permission.get({ gid }) + const effectivePerm = existingPerm?.inherit === false ? existingPerm : groupPerm + request.payload = Authz.capPermissions(userPerm, request.payload, existingPerm) + + const hasPermFields = Object.keys(request.payload).some((field) => PERM_FIELDS.has(field)) + if ( + request.payload.inherit_group_permissions === false + || (!existingPerm && hasPermFields) + ) { + request.payload = Authz.preserveUnmanagedPermissions( + userPerm, request.payload, effectivePerm, + ) + } + + const permFields = extractPermFields(request.payload) + + request.payload = pickFields(request.payload, USER_PUT_FIELDS) + + // switching yourself back to inherited permissions adopts the group's, + // which capPermissions can't cap because it isn't a permission field + if (id === user.id) delete request.payload.inherit_group_permissions + + if (request.payload.inherit_group_permissions !== undefined) { + const after = request.payload.inherit_group_permissions + ? groupPerm + : existingPerm ?? {} + if (!Authz.canTransitionPermissions(userPerm, effectivePerm, after)) { + delete request.payload.inherit_group_permissions + } else if (request.payload.inherit_group_permissions === true) { + for (const field of Object.keys(permFields)) delete permFields[field] + } + } + const args = { ...request.payload, id } // no salt passed: a password change always gets a fresh one @@ -142,11 +248,26 @@ function UserRoutes(server) { await User.put(args) + if (Object.keys(permFields).length > 0) { + let perm = await Permission.get({ uid: id }) + if (!perm) { + const [userData] = await User.get({ id }) + const permId = await Permission.create({ + uid: id, + gid: userData.gid, + inherit: false, + name: `User ${userData.username} perms`, + }) + perm = await Permission.get({ id: permId }) + } + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } + const users = await User.get({ id }) if (!users.length) { return h.response({ meta: { api: meta.api, msg: `user not found` } }).code(404) } - delete users[0].gid + prepareUserResponse(users[0]) return h .response({ @@ -160,6 +281,7 @@ function UserRoutes(server) { method: 'DELETE', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.user.DELETE, }, @@ -184,7 +306,7 @@ function UserRoutes(server) { await User.delete({ id: users[0].id }) - delete users[0].gid + prepareUserResponse(users[0]) return h .response({ diff --git a/routes/user.test.js b/routes/user.test.js index da88079..a19cdea 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -6,6 +6,7 @@ import User from '../lib/user/index.js' import Group from '../lib/group/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } let server, @@ -15,6 +16,7 @@ before(async () => { server = await init() await Group.create(groupCase) await User.create(userCase) + await grantGroupPermissions(groupCase.id) }) const userId2 = 4094 diff --git a/routes/zone.js b/routes/zone.js index a3462d0..6b25dbb 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -2,15 +2,29 @@ import validate from '@nictool/validate' import Zone from '../lib/zone/index.js' import Group from '../lib/group/index.js' +import Authz from '../lib/authz.js' import Mysql from '../lib/mysql.js' import { meta } from '../lib/util.js' +const ZONE_PUT_FIELDS = new Set([ + 'description', 'mailaddr', 'serial', 'ttl', 'refresh', 'retry', 'expire', 'minimum', + 'deleted', +]) + function ZoneRoutes(server) { server.route([ { method: 'GET', path: '/zone/{id?}', options: { + app: { + permission: { + resource: 'zone', + action: 'read', + idFrom: 'params.id', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.zone.GET_req, }, @@ -20,12 +34,16 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const deleted = request.query.deleted === true const getArgs = { - deleted, limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) + if (!request.params.id && request.query.gid == null) { + getArgs.gid = request.auth.credentials.group.id + } if (request.query.gid != null) { const gid = Number.isInteger(request.query.gid) ? request.query.gid @@ -43,19 +61,31 @@ function ZoneRoutes(server) { getArgs.gid = await Group.subgroupGids(getArgs.gid) } + if (!getArgs.id && getArgs.gid !== undefined) { + getArgs.accessible_ids = await Authz.getDelegatedZoneIds(getArgs.gid) + } + + const deleted = getArgs.deleted ?? false const countArgs = { deleted, ...(getArgs.id ? { id: getArgs.id } : {}), ...(getArgs.gid ? { gid: getArgs.gid } : {}), + ...(getArgs.accessible_ids ? { accessible_ids: getArgs.accessible_ids } : {}), ...(getArgs.search ? { search: getArgs.search } : {}), ...(getArgs.zone_like ? { zone_like: getArgs.zone_like } : {}), ...(getArgs.description_like ? { description_like: getArgs.description_like } : {}), } + const totalArgs = { + deleted, + ...(getArgs.id ? { id: getArgs.id } : {}), + ...(getArgs.gid ? { gid: getArgs.gid } : {}), + ...(getArgs.accessible_ids ? { accessible_ids: getArgs.accessible_ids } : {}), + } const [zones, filtered, total] = await Promise.all([ Zone.get(getArgs), Zone.count(countArgs), - Zone.count(getArgs.id ? { deleted, id: getArgs.id } : { deleted }), + Zone.count(totalArgs), ]) return h @@ -79,6 +109,7 @@ function ZoneRoutes(server) { method: 'POST', path: '/zone', options: { + app: { permission: { resource: 'zone', action: 'create' } }, validate: { payload: validate.zone.POST, }, @@ -107,8 +138,10 @@ function ZoneRoutes(server) { method: 'PUT', path: '/zone/{id}', options: { + app: { permission: { resource: 'zone', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.zone.PUT, + options: { allowUnknown: true }, }, response: { schema: validate.zone.GET_res, @@ -124,7 +157,10 @@ function ZoneRoutes(server) { return h.response({ meta: { api: meta.api, msg: `I couldn't find that zone` } }).code(404) } - await Zone.put({ id, ...request.payload }) + const payload = Object.fromEntries( + Object.entries(request.payload).filter(([key]) => ZONE_PUT_FIELDS.has(key)), + ) + await Zone.put({ id, ...payload }) const updated = await Zone.get({ id }) return h.response({ zone: updated, meta: { api: meta.api, msg: `the zone was updated` } }).code(200) @@ -134,6 +170,7 @@ function ZoneRoutes(server) { method: 'GET', path: '/zone/{id}/ns', options: { + app: { permission: { resource: 'zone', action: 'read', idFrom: 'params.id' } }, response: { schema: validate.zone.GET_ns_res, }, @@ -165,6 +202,7 @@ function ZoneRoutes(server) { method: 'DELETE', path: '/zone/{id}', options: { + app: { permission: { resource: 'zone', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.zone.DELETE, }, diff --git a/routes/zone.test.js b/routes/zone.test.js index 86c3eb7..779bb5a 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -7,6 +7,7 @@ import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/zone.json' with { type: 'json' } @@ -25,7 +26,11 @@ before(async () => { // nt_group_subgroups closure row (and thus the include_subgroups query) empty. await Group.destroy({ id: subGroup.id }) await Group.create(groupCase) + // POST /zone (case2Id) targets a zone in this group; authz requires it to + // exist inside the fixture user's group tree. + await Group.create({ id: case2Id, parent_gid: groupCase.id, name: 'route2.example.com' }) await User.create(userCase) + await grantGroupPermissions(groupCase.id) await Zone.create(nsCase) await Group.create(subGroup) await Zone.create(subZone) @@ -35,6 +40,7 @@ before(async () => { after(async () => { await Zone.destroy({ id: subZone.id }) await Group.destroy({ id: subGroup.id }) + await Group.destroy({ id: case2Id }) await server.stop() }) diff --git a/routes/zone_record.js b/routes/zone_record.js index 285cb33..6336709 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -2,6 +2,7 @@ import validate from '@nictool/validate' import ZoneRecord from '../lib/zone_record/index.js' import Zone from '../lib/zone/index.js' +import Authz from '../lib/authz.js' import { meta } from '../lib/util.js' async function zoneRecordResponseFailAction(request, h, err) { @@ -33,6 +34,14 @@ function ZoneRecordRoutes(server) { method: 'GET', path: '/zone_record/{id?}', options: { + app: { + permission: { + resource: 'zonerecord', + action: 'read', + idFrom: 'params.id', + list: { resource: 'zone', idFrom: 'query.zid' }, + }, + }, validate: { query: validate.zone_record.GET_req, }, @@ -55,7 +64,18 @@ function ZoneRecordRoutes(server) { if (request.query.sort_by) getArgs.sort_by = request.query.sort_by if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir - const scope = getArgs.id ? { id: getArgs.id } : getArgs.zid ? { zid: getArgs.zid } : {} + if (!getArgs.id && getArgs.zid) { + const ids = await Authz.getZoneRecordReadScope( + request.auth.credentials.group.id, getArgs.zid, + ) + if (ids !== null) getArgs.ids = ids + } + + const scope = getArgs.id + ? { id: getArgs.id } + : getArgs.zid + ? { zid: getArgs.zid, ...(getArgs.ids ? { ids: getArgs.ids } : {}) } + : {} const countArgs = { deleted, ...scope, @@ -90,6 +110,7 @@ function ZoneRecordRoutes(server) { method: 'POST', path: '/zone_record', options: { + app: { permission: { resource: 'zonerecord', action: 'create' } }, validate: { payload: validate.zone_record.POST, }, @@ -118,6 +139,14 @@ function ZoneRecordRoutes(server) { method: 'PUT', path: '/zone_record/{id}', options: { + app: { + permission: { + resource: 'zonerecord', + action: 'write', + idFrom: 'params.id', + targetCreateResource: 'zonerecord', + }, + }, validate: { payload: validate.zone_record.PUT, }, @@ -149,6 +178,7 @@ function ZoneRecordRoutes(server) { method: 'DELETE', path: '/zone_record/{id}', options: { + app: { permission: { resource: 'zonerecord', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.zone_record.DELETE, }, diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index 5e96845..3b049b5 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -8,6 +8,7 @@ import Zone from '../lib/zone/index.js' import ZoneRecord from '../lib/zone_record/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } import zoneCase from './test/zone.json' with { type: 'json' } @@ -49,6 +50,7 @@ before(async () => { await Group.create(testGroup) await User.create(testUser) + await grantGroupPermissions(testGroup.id) await Zone.create(testZone) await ZoneRecord.create(testZoneRecord) From 8a26b99ec1ad10ce0fbc256b60e83a2f3fdd8043 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:15:43 +0100 Subject: [PATCH 02/48] user: add searchable listings --- lib/user/store/file.js | 26 ++++++++++++++++++++++++++ lib/user/store/mysql.js | 37 +++++++++++++++++++++++++++++++++++++ lib/user/test/index.js | 33 +++++++++++++++++++++++++++++++++ routes/user.js | 26 +++++++++++++++++++++++++- routes/user.test.js | 13 +++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/lib/user/store/file.js b/lib/user/store/file.js index 4ee232e..4fb135c 100644 --- a/lib/user/store/file.js +++ b/lib/user/store/file.js @@ -99,6 +99,24 @@ class UserRepoFile extends UserBase { if (deletedArg === false) users = users.filter((u) => !u.deleted) else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) + const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' + if (search) { + users = users.filter((u) => { + const username = u.username.toLowerCase() + return args.exact_match === true ? username === search : username.includes(search) + }) + } + + const sortBy = ['id', 'username', 'email', 'first_name', 'last_name'].includes(args.sort_by) + ? args.sort_by + : 'username' + const direction = args.sort_dir === 'desc' ? -1 : 1 + users.sort((a, b) => `${a[sortBy] ?? ''}`.localeCompare(`${b[sortBy] ?? ''}`) * direction) + + const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 + const limit = Number.isInteger(args.limit) ? Math.max(1, args.limit) : users.length + users = users.slice(offset, offset + limit) + const result = [] for (const u of users) { const r = this._postProcess(u, deletedArg) @@ -127,6 +145,14 @@ class UserRepoFile extends UserBase { if (deletedArg === false) users = users.filter((u) => !u.deleted) else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) + const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' + if (search) { + users = users.filter((u) => { + const username = u.username.toLowerCase() + return args.exact_match === true ? username === search : username.includes(search) + }) + } + return users.length } diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 63457f1..bb04e5b 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -109,6 +109,28 @@ class UserRepoMySQL extends UserBase { const include_subgroups = args.include_subgroups === true delete args.include_subgroups + const search = typeof args.search === 'string' ? args.search.trim() : '' + delete args.search + const exactMatch = args.exact_match === true + delete args.exact_match + + const sortByMap = { + id: 'nt_user_id', + username: 'username', + email: 'email', + first_name: 'first_name', + last_name: 'last_name', + } + const sortBy = sortByMap[args.sort_by] ?? 'username' + const sortDir = args.sort_dir === 'desc' ? 'DESC' : 'ASC' + delete args.sort_by + delete args.sort_dir + + const limit = Number.isInteger(args.limit) ? Math.max(1, args.limit) : undefined + delete args.limit + const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 + delete args.offset + let query = `SELECT email , first_name , last_name @@ -156,10 +178,18 @@ class UserRepoMySQL extends UserBase { delete args.deleted } + if (search) { + where.push(`username ${exactMatch ? '=' : 'LIKE'} ?`) + params.push(exactMatch ? search : `%${search}%`) + } + if (where.length > 0) { query += ` WHERE ${where.join(' AND ')}` } + query += ` ORDER BY ${sortBy} ${sortDir}` + if (limit !== undefined) query += ` LIMIT ${limit} OFFSET ${offset}` + const rows = await Mysql.execute(query, params) for (const r of rows) { for (const b of boolFields) { @@ -183,6 +213,9 @@ class UserRepoMySQL extends UserBase { const params = [] const where = [] + const search = typeof args.search === 'string' ? args.search.trim() : '' + const exactMatch = args.exact_match === true + if (args.id !== undefined) { where.push('nt_user_id = ?') params.push(args.id) @@ -199,6 +232,10 @@ class UserRepoMySQL extends UserBase { where.push('deleted = ?') params.push(args.deleted === true ? 1 : 0) } + if (search) { + where.push(`username ${exactMatch ? '=' : 'LIKE'} ?`) + params.push(exactMatch ? search : `%${search}%`) + } let query = 'SELECT COUNT(*) AS count FROM nt_user' if (where.length > 0) query += ` WHERE ${where.join(' AND ')}` diff --git a/lib/user/test/index.js b/lib/user/test/index.js index 5eeb8f0..1953659 100644 --- a/lib/user/test/index.js +++ b/lib/user/test/index.js @@ -85,6 +85,39 @@ describe('user', function () { const u = await User.get({ username: userCase.username }) assert.deepEqual(sanitizeActual(u[0]), sanitize(userCase)) }) + + it('searches, sorts, and paginates users', async () => { + const ids = [] + for (const username of ['unit-search-alpha', 'unit-search-beta', 'unit-search-gamma']) { + ids.push(await User.create({ + ...userCase, + id: undefined, + username, + email: `${username}@example.com`, + })) + } + try { + const exact = await User.get({ + gid: userCase.gid, + search: 'unit-search-beta', + exact_match: true, + }) + assert.deepEqual(exact.map((u) => u.username), ['unit-search-beta']) + + const page = await User.get({ + gid: userCase.gid, + search: 'unit-search-', + sort_by: 'username', + sort_dir: 'desc', + limit: 2, + offset: 1, + }) + assert.deepEqual(page.map((u) => u.username), ['unit-search-beta', 'unit-search-alpha']) + assert.equal(await User.count({ gid: userCase.gid, search: 'unit-search-' }), 3) + } finally { + for (const id of ids) await User.destroy({ id }) + } + }) }) describe('PUT', function () { diff --git a/routes/user.js b/routes/user.js index 1bfc531..271d522 100644 --- a/routes/user.js +++ b/routes/user.js @@ -77,9 +77,27 @@ function UserRoutes(server) { gid: parseInt(gid, 10), deleted: request.query.deleted ?? false, include_subgroups: request.query.include_subgroups === true, + limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, } - const users = await User.get(getArgs) + if (request.query.search) getArgs.search = request.query.search + if (request.query.exact_match === true) getArgs.exact_match = true + if (Number.isInteger(request.query.offset)) getArgs.offset = request.query.offset + if (request.query.sort_by) getArgs.sort_by = request.query.sort_by + if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir + + const countArgs = { + gid: getArgs.gid, + deleted: getArgs.deleted, + ...(getArgs.search ? { search: getArgs.search } : {}), + ...(getArgs.exact_match ? { exact_match: true } : {}), + } + const totalArgs = { gid: getArgs.gid, deleted: getArgs.deleted } + const [users, filtered, total] = await Promise.all([ + User.get(getArgs), + User.count(countArgs), + User.count(totalArgs), + ]) for (const u of users) prepareUserResponse(u) return h @@ -88,6 +106,12 @@ function UserRoutes(server) { meta: { api: meta.api, msg: `users in group`, + pagination: { + total, + filtered, + limit: getArgs.limit, + offset: getArgs.offset ?? 0, + }, }, }) .code(200) diff --git a/routes/user.test.js b/routes/user.test.js index a19cdea..8b0fe64 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -74,6 +74,19 @@ describe('user routes', () => { assert.equal(res.statusCode, 201) }) + it('GET /user searches and paginates', async () => { + const res = await server.inject({ + method: 'GET', + url: `/user?gid=${userCase.gid}&search=${userCase.username}2&exact_match=true&limit=10&offset=0&sort_by=username&sort_dir=desc`, + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.user.map((u) => u.username), [`${userCase.username}2`]) + assert.equal(res.result.meta.pagination.filtered, 1) + assert.equal(res.result.meta.pagination.limit, 10) + assert.equal(res.result.meta.pagination.offset, 0) + }) + it(`GET /user/${userId2}`, async () => { const res = await server.inject({ method: 'GET', From 7bca350d8706c552467c666e1d66ea3896aedf9f Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:34:56 +0100 Subject: [PATCH 03/48] objects: move between groups --- lib/authz-plugin.js | 12 ++++++++++++ routes/authz.test.js | 33 ++++++++++++++++++++++++++++----- routes/nameserver.js | 9 ++++++++- routes/nameserver.test.js | 23 +++++++++++++++++++++++ routes/user.js | 11 +++++++++-- routes/user.test.js | 23 +++++++++++++++++++++++ routes/zone.js | 11 +++++++++-- routes/zone.test.js | 20 ++++++++++++++++++++ 8 files changed, 132 insertions(+), 10 deletions(-) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index 872888e..ba3da25 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -79,6 +79,18 @@ const authzPlugin = { if (permCfg.targetGroupFrom) { const targetGid = resolveId(request, permCfg.targetGroupFrom) if (targetGid !== undefined) { + if ( + resource === 'user' + && action === 'write' + && objectId === credentials.user.id + && Number(targetGid) !== credentials.group.id + ) { + return respond({ + allowed: false, + code: 403, + msg: `Cannot move yourself to another group`, + }, h) + } if ( resource === 'group' && action === 'write' diff --git a/routes/authz.test.js b/routes/authz.test.js index 541f951..12805c8 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -347,17 +347,29 @@ describe('authz plugin - zone routes', () => { assert.equal(res.statusCode, 200) }) - it('does not pass unknown fields or gid changes to the zone store', async () => { + it('403 when moving a zone outside the caller tree', async () => { const res = await server.inject({ method: 'PUT', url: `/zone/${Z_INTREE.id}`, headers: authFull.headers, - payload: { ttl: 7201, serial: 7, gid: G_OUTSIDE.id, malicious: 'not-a-column' }, + payload: { gid: G_OUTSIDE.id }, }) - assert.equal(res.statusCode, 200) + assert.equal(res.statusCode, 403) const [zone] = await Zone.get({ id: Z_INTREE.id }) assert.equal(zone.gid, G_ROOT.id) + }) + + it('does not pass unknown fields to the zone store', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { ttl: 7201, serial: 7, malicious: 'not-a-column' }, + }) + assert.equal(res.statusCode, 200) + + const [zone] = await Zone.get({ id: Z_INTREE.id }) assert.equal(zone.ttl, 7201) assert.equal(zone.serial, 7) }) @@ -401,7 +413,19 @@ describe('authz plugin - zone routes', () => { }) describe('authz plugin - user self-ops', () => { - it('does not pass unknown fields, gid changes, or is_admin through self-write', async () => { + it('403 when moving yourself to another group', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { gid: G_CHILD.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Cannot move yourself/) + assert.equal((await User.get({ id: U_FULL.id }))[0].gid, G_ROOT.id) + }) + + it('does not pass unknown fields or is_admin through self-write', async () => { const [before] = await Mysql.execute( 'SELECT is_admin FROM nt_user WHERE nt_user_id = ?', [U_FULL.id], @@ -412,7 +436,6 @@ describe('authz plugin - user self-ops', () => { headers: authFull.headers, payload: { first_name: 'Still Full', - gid: G_OUTSIDE.id, is_admin: true, malicious: 'not-a-column', }, diff --git a/routes/nameserver.js b/routes/nameserver.js index aea0647..9b85ca0 100644 --- a/routes/nameserver.js +++ b/routes/nameserver.js @@ -85,7 +85,14 @@ function NameserverRoutes(server) { method: 'PUT', path: '/nameserver/{id}', options: { - app: { permission: { resource: 'nameserver', action: 'write', idFrom: 'params.id' } }, + app: { + permission: { + resource: 'nameserver', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.gid', + }, + }, validate: { payload: validate.nameserver.PUT, }, diff --git a/routes/nameserver.test.js b/routes/nameserver.test.js index 7811113..6401cdf 100644 --- a/routes/nameserver.test.js +++ b/routes/nameserver.test.js @@ -13,10 +13,12 @@ import nsCase from './test/nameserver.json' with { type: 'json' } let server let case2Id = 4094 +const moveGroup = { id: 4090, parent_gid: groupCase.id, name: 'ns-move.route.example.com' } before(async () => { await Nameserver.destroy({ id: case2Id }) await Group.create(groupCase) + await Group.create(moveGroup) await User.create(userCase) await grantGroupPermissions(groupCase.id) await Nameserver.create(nsCase) @@ -25,6 +27,7 @@ before(async () => { after(async () => { await Nameserver.destroy({ id: case2Id }) + await Group.destroy({ id: moveGroup.id }) await server.stop() }) @@ -55,6 +58,26 @@ describe('nameserver routes', () => { assert.equal(res.result.nameserver[0].name, nsCase.name) }) + it(`PUT /nameserver/${nsCase.id} moves it to another group`, async () => { + const moved = await server.inject({ + method: 'PUT', + url: `/nameserver/${nsCase.id}`, + headers: auth.headers, + payload: { gid: moveGroup.id }, + }) + assert.equal(moved.statusCode, 200) + assert.equal(moved.result.nameserver[0].gid, moveGroup.id) + + const restored = await server.inject({ + method: 'PUT', + url: `/nameserver/${nsCase.id}`, + headers: auth.headers, + payload: { gid: groupCase.id }, + }) + assert.equal(restored.statusCode, 200) + assert.equal(restored.result.nameserver[0].gid, groupCase.id) + }) + it(`POST /nameserver (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique diff --git a/routes/user.js b/routes/user.js index 271d522..81a7417 100644 --- a/routes/user.js +++ b/routes/user.js @@ -21,7 +21,7 @@ const USER_POST_FIELDS = new Set([ ]) const USER_PUT_FIELDS = new Set([ - 'first_name', 'last_name', 'username', 'email', 'password', + 'gid', 'first_name', 'last_name', 'username', 'email', 'password', 'deleted', 'inherit_group_permissions', ]) @@ -214,7 +214,14 @@ function UserRoutes(server) { method: 'PUT', path: '/user/{id}', options: { - app: { permission: { resource: 'user', action: 'write', idFrom: 'params.id' } }, + app: { + permission: { + resource: 'user', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.gid', + }, + }, validate: { payload: validate.user.PUT, options: { allowUnknown: true }, diff --git a/routes/user.test.js b/routes/user.test.js index 8b0fe64..1ebabc1 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -15,14 +15,17 @@ let server, before(async () => { server = await init() await Group.create(groupCase) + await Group.create(moveGroup) await User.create(userCase) await grantGroupPermissions(groupCase.id) }) const userId2 = 4094 +const moveGroup = { id: 4090, parent_gid: groupCase.id, name: 'user-move.route.example.com' } after(async () => { User.destroy({ id: userId2 }) + await Group.destroy({ id: moveGroup.id }) await server.stop() }) @@ -87,6 +90,26 @@ describe('user routes', () => { assert.equal(res.result.meta.pagination.offset, 0) }) + it(`PUT /user/${userId2} moves it to another group`, async () => { + const moved = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { gid: moveGroup.id }, + }) + assert.equal(moved.statusCode, 200) + assert.equal((await User.get({ id: userId2 }))[0].gid, moveGroup.id) + + const restored = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { gid: groupCase.id }, + }) + assert.equal(restored.statusCode, 200) + assert.equal((await User.get({ id: userId2 }))[0].gid, groupCase.id) + }) + it(`GET /user/${userId2}`, async () => { const res = await server.inject({ method: 'GET', diff --git a/routes/zone.js b/routes/zone.js index 6b25dbb..eccb016 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -7,7 +7,7 @@ import Mysql from '../lib/mysql.js' import { meta } from '../lib/util.js' const ZONE_PUT_FIELDS = new Set([ - 'description', 'mailaddr', 'serial', 'ttl', 'refresh', 'retry', 'expire', 'minimum', + 'gid', 'description', 'mailaddr', 'serial', 'ttl', 'refresh', 'retry', 'expire', 'minimum', 'deleted', ]) @@ -138,7 +138,14 @@ function ZoneRoutes(server) { method: 'PUT', path: '/zone/{id}', options: { - app: { permission: { resource: 'zone', action: 'write', idFrom: 'params.id' } }, + app: { + permission: { + resource: 'zone', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.gid', + }, + }, validate: { payload: validate.zone.PUT, options: { allowUnknown: true }, diff --git a/routes/zone.test.js b/routes/zone.test.js index 779bb5a..d16e644 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -81,6 +81,26 @@ describe('zone routes', () => { assert.ok(res.result.zone.some((z) => z.zone === nsCase.zone)) }) + it(`PUT /zone/${nsCase.id} moves it to another group`, async () => { + const moved = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { gid: case2Id }, + }) + assert.equal(moved.statusCode, 200) + assert.equal(moved.result.zone[0].gid, case2Id) + + const restored = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { gid: groupCase.id }, + }) + assert.equal(restored.statusCode, 200) + assert.equal(restored.result.zone[0].gid, groupCase.id) + }) + it(`POST /zone (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique From 1f2571db5eac3e63b3ec0c120ae2d0222269aa89 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:45:10 +0100 Subject: [PATCH 04/48] log: persist and serve audit entries --- lib/audit.js | 220 +++++++++++++++++++++++++++++++++++++ lib/audit.test.js | 99 +++++++++++++++++ routes/authz.test.js | 18 +++ routes/index.js | 2 + routes/log.js | 82 ++++++++++++++ routes/zone.js | 19 +++- routes/zone.test.js | 26 +++++ routes/zone_record.js | 23 ++++ routes/zone_record.test.js | 9 ++ 9 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 lib/audit.js create mode 100644 lib/audit.test.js create mode 100644 routes/log.js diff --git a/lib/audit.js b/lib/audit.js new file mode 100644 index 0000000..bf10ab4 --- /dev/null +++ b/lib/audit.js @@ -0,0 +1,220 @@ +import * as RR from '@nictool/dns-resource-record' + +import Mysql from './mysql.js' + +const actionDescription = { + added: 'initial creation', + deleted: 'deleted', + modified: 'modified', + moved: 'moved', + recovered: 'recovered', +} + +class Audit { + async logZone(actor, action, zone, previous = {}) { + const timestamp = Math.floor(Date.now() / 1000) + const detail = compact({ + nt_group_id: zone.gid, + nt_zone_id: zone.id, + nt_user_id: actor.id, + action, + timestamp, + zone: zone.zone, + mailaddr: zone.mailaddr, + description: zone.description, + refresh: zone.refresh, + retry: zone.retry, + expire: zone.expire, + ttl: zone.ttl, + minimum: zone.minimum, + serial: zone.serial, + }) + const logId = await Mysql.execute(...Mysql.insert('nt_zone_log', detail)) + await this.logGlobal({ + uid: actor.id, + timestamp, + action, + object: 'zone', + objectId: zone.id, + logId, + title: zone.zone, + description: describe(action, 'zone', zone, previous), + }) + return logId + } + + async logZoneRecord(actor, action, record, zone, previous = {}) { + const timestamp = Math.floor(Date.now() / 1000) + const detail = compact({ + nt_zone_id: record.zid, + nt_zone_record_id: record.id, + nt_user_id: actor.id, + action, + timestamp, + name: record.owner, + ttl: record.ttl, + description: record.description, + type_id: RR.typeMap[record.type], + address: record.address, + weight: record.weight, + priority: record.priority, + other: record.other, + location: record.location, + }) + const logId = await Mysql.execute(...Mysql.insert('nt_zone_record_log', detail)) + await this.logGlobal({ + uid: actor.id, + timestamp, + action, + object: 'zone_record', + objectId: record.id, + logId, + title: record.owner, + description: describe(action, 'record', record, previous, zone), + }) + return logId + } + + async logGlobal({ uid, timestamp, action, object, objectId, logId, title, description }) { + return Mysql.execute(...Mysql.insert('nt_user_global_log', { + nt_user_id: uid, + timestamp, + action, + object, + object_id: objectId, + log_entry_id: logId, + title, + description, + })) + } + + async listGlobal(args) { + const scope = groupScope('u.nt_group_id', args.gids) + const where = Number.isInteger(args.uid) + ? `${scope.sql} AND gl.nt_user_id = ?` + : scope.sql + const params = Number.isInteger(args.uid) ? [...scope.params, args.uid] : scope.params + return listRows({ + select: `SELECT gl.nt_user_global_log_id AS id, + gl.nt_user_id AS uid, u.nt_group_id AS gid, g.name AS group_name, + gl.timestamp, gl.action, gl.object, gl.object_id, gl.target, + gl.target_id, gl.target_name, gl.log_entry_id, gl.title, gl.description, + CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, + from: `FROM nt_user_global_log gl + JOIN nt_user u ON u.nt_user_id = gl.nt_user_id + JOIN nt_group g ON g.nt_group_id = u.nt_group_id`, + where, + params, + searchColumns: ['u.username', 'gl.action', 'gl.object', 'gl.title', 'gl.description'], + sortMap: { + timestamp: 'gl.timestamp', user: 'u.username', action: 'gl.action', + object: 'gl.object', title: 'gl.title', description: 'gl.description', + group_name: 'g.name', + }, + args, + }) + } + + async listZones(args) { + const scope = groupScope('zl.nt_group_id', args.gids) + return listRows({ + select: `SELECT zl.nt_zone_log_id AS id, zl.nt_group_id AS gid, + zl.nt_user_id AS uid, zl.nt_zone_id AS zid, zl.timestamp, zl.action, + zl.zone, zl.mailaddr, zl.description, zl.serial, zl.refresh, zl.retry, + zl.expire, zl.minimum, zl.ttl, zl.location, g.name AS group_name, + CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, + from: `FROM nt_zone_log zl + JOIN nt_user u ON u.nt_user_id = zl.nt_user_id + JOIN nt_group g ON g.nt_group_id = zl.nt_group_id`, + where: scope.sql, + params: scope.params, + searchColumns: ['zl.zone', 'zl.description', 'zl.action', 'u.username', 'g.name'], + sortMap: { + timestamp: 'zl.timestamp', user: 'u.username', action: 'zl.action', + zone: 'zl.zone', ttl: 'zl.ttl', description: 'zl.description', group_name: 'g.name', + }, + args, + }) + } + + async listZoneRecords(args) { + const where = Number.isInteger(args.id) + ? 'rl.nt_zone_id = ? AND rl.nt_zone_record_log_id = ?' + : 'rl.nt_zone_id = ?' + const params = Number.isInteger(args.id) ? [args.zid, args.id] : [args.zid] + return listRows({ + select: `SELECT rl.nt_zone_record_log_id AS id, rl.nt_zone_id AS zid, + rl.nt_user_id AS uid, rl.nt_zone_record_id AS zrid, rl.timestamp, + rl.action, rl.name AS owner, rl.ttl, rl.description, rt.name AS type, + rl.address, rl.weight, rl.priority, rl.other, rl.location, + CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, + from: `FROM nt_zone_record_log rl + JOIN nt_user u ON u.nt_user_id = rl.nt_user_id + JOIN resource_record_type rt ON rt.id = rl.type_id`, + where, + params, + searchColumns: [ + 'rl.name', 'rl.description', 'rt.name', 'rl.address', 'rl.action', 'u.username', + ], + sortMap: { + timestamp: 'rl.timestamp', user: 'u.username', action: 'rl.action', + owner: 'rl.name', type: 'rt.name', address: 'rl.address', ttl: 'rl.ttl', + weight: 'rl.weight', description: 'rl.description', + }, + args, + }) + } +} + +async function listRows({ select, from, where, params, searchColumns, sortMap, args }) { + const limit = Number.isInteger(args.limit) ? args.limit : 50 + const offset = Number.isInteger(args.offset) ? args.offset : 0 + const total = await countRows(from, where, params) + + let filteredWhere = where + const filteredParams = [...params] + const search = typeof args.search === 'string' ? args.search.trim() : '' + if (search) { + const op = args.exact_match === true ? '= ?' : 'LIKE ?' + filteredWhere += ` AND (${searchColumns.map((column) => `${column} ${op}`).join(' OR ')})` + const value = args.exact_match === true ? search : `%${search}%` + filteredParams.push(...searchColumns.map(() => value)) + } + + const filtered = search ? await countRows(from, filteredWhere, filteredParams) : total + const sortBy = sortMap[args.sort_by] ?? sortMap.timestamp + const sortDir = args.sort_dir === 'asc' ? 'ASC' : 'DESC' + const rows = await Mysql.execute( + `${select} ${from} WHERE ${filteredWhere} + ORDER BY ${sortBy} ${sortDir}, id DESC LIMIT ${limit} OFFSET ${offset}`, + filteredParams, + ) + return { rows, total, filtered, limit, offset } +} + +async function countRows(from, where, params) { + const rows = await Mysql.execute(`SELECT COUNT(*) AS total ${from} WHERE ${where}`, params) + return rows[0].total +} + +function groupScope(column, gids) { + const values = (Array.isArray(gids) ? gids : [gids]).map(Number).filter(Number.isInteger) + if (values.length === 0) return { sql: '1 = 0', params: [] } + return { + sql: `${column} IN (${values.map(() => '?').join(', ')})`, + params: values, + } +} + +function compact(obj) { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) +} + +function describe(action, object, current, previous, zone) { + if (action === 'moved') return `moved from group ${previous.gid} to ${current.gid}` + if (action === 'deleted' && object === 'record') return `deleted record from ${zone.zone}` + if (action === 'recovered' && object === 'record') return `recovered ${current.type} record` + return `${actionDescription[action] ?? action} ${object}` +} + +export default new Audit() diff --git a/lib/audit.test.js b/lib/audit.test.js new file mode 100644 index 0000000..2dd6d71 --- /dev/null +++ b/lib/audit.test.js @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { after, before, describe, it } from 'node:test' + +import Audit from './audit.js' +import Group from './group/index.js' +import Mysql from './mysql.js' +import User from './user/index.js' +import Zone from './zone/index.js' +import ZoneRecord from './zone_record/index.js' + +const gid = 6190 +const zid = 6190 +const zrid = 6190 +const actor = { id: 6190 } +const zone = { + id: zid, + gid, + zone: 'audit.example.com.', + mailaddr: 'hostmaster.audit.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const record = { + id: zrid, + zid, + owner: 'www.audit.example.com.', + type: 'A', + address: '192.0.2.19', + ttl: 300, +} + +before(async () => { + await Mysql.execute('DELETE FROM nt_user_global_log WHERE nt_user_id = ?', [actor.id]) + await ZoneRecord.destroy({ id: zrid }) + await Zone.destroy({ id: zid }) + await User.destroy({ id: actor.id }) + await Group.destroy({ id: gid }) + await Group.create({ id: gid, parent_gid: 0, name: 'audit-test' }) + await User.create({ + id: actor.id, + gid, + username: 'audit-test', + email: 'audit-test@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'Audit', + last_name: 'Tester', + }) + await Zone.create(zone) + await ZoneRecord.create(record) +}) + +after(async () => { + await Mysql.execute('DELETE FROM nt_user_global_log WHERE nt_user_id = ?', [actor.id]) + await ZoneRecord.destroy({ id: zrid }) + await Zone.destroy({ id: zid }) + await User.destroy({ id: actor.id }) + await Group.destroy({ id: gid }) + await Mysql.disconnect() +}) + +describe('audit log', () => { + it('records and lists zone changes', async () => { + await Audit.logZone(actor, 'added', zone) + const result = await Audit.listZones({ gids: [gid], search: 'audit.example.com.' }) + assert.equal(result.filtered, 1) + assert.equal(result.rows[0].action, 'added') + assert.equal(result.rows[0].zone, zone.zone) + assert.equal(result.rows[0].zid, zid) + }) + + it('records and lists zone-record changes with their type', async () => { + const id = await Audit.logZoneRecord(actor, 'deleted', record, zone) + const result = await Audit.listZoneRecords({ zid, search: 'www.audit' }) + assert.equal(result.filtered, 1) + assert.equal(result.rows[0].action, 'deleted') + assert.equal(result.rows[0].owner, record.owner) + assert.equal(result.rows[0].type, 'A') + + const exact = await Audit.listZoneRecords({ zid, id }) + assert.equal(exact.total, 1) + assert.equal(exact.rows[0].id, id) + assert.equal((await Audit.listZoneRecords({ zid, id: id + 1 })).total, 0) + }) + + it('lists the actor global log with stable pagination', async () => { + const result = await Audit.listGlobal({ gids: [gid], limit: 1, offset: 0 }) + assert.equal(result.total, 2) + assert.equal(result.filtered, 2) + assert.equal(result.rows.length, 1) + assert.equal(result.rows[0].uid, actor.id) + + assert.equal((await Audit.listGlobal({ gids: [gid], uid: actor.id })).total, 2) + assert.equal((await Audit.listGlobal({ gids: [gid], uid: actor.id + 1 })).total, 0) + }) +}) diff --git a/routes/authz.test.js b/routes/authz.test.js index 12805c8..74ad860 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -317,6 +317,24 @@ describe('authz plugin - zone routes', () => { assert.equal(res.statusCode, 403) }) + it('403 for zone logs scoped outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/log/zone?gid=${G_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for record logs on a zone outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + }) + it('403 for POST /zone when user lacks zone.create', async () => { const res = await server.inject({ method: 'POST', diff --git a/routes/index.js b/routes/index.js index 399b2dc..701f745 100644 --- a/routes/index.js +++ b/routes/index.js @@ -25,6 +25,7 @@ import { NameserverRoutes } from './nameserver.js' import { ZoneRoutes } from './zone.js' import { ZoneRecordRoutes } from './zone_record.js' import { DelegationRoutes } from './delegation.js' +import { LogRoutes } from './log.js' import authzPlugin from '../lib/authz-plugin.js' let server @@ -125,6 +126,7 @@ async function setup() { ZoneRoutes(server) ZoneRecordRoutes(server) DelegationRoutes(server) + LogRoutes(server) server.route({ method: '*', diff --git a/routes/log.js b/routes/log.js new file mode 100644 index 0000000..a9e05c4 --- /dev/null +++ b/routes/log.js @@ -0,0 +1,82 @@ +import validate from '@nictool/validate' + +import Audit from '../lib/audit.js' +import Group from '../lib/group/index.js' +import { meta } from '../lib/util.js' + +function LogRoutes(server) { + server.route([ + { + method: 'GET', + path: '/log/global', + options: groupLogOptions(), + handler: async (request, h) => { + const gids = await groupScope(request) + return logResponse(h, await Audit.listGlobal({ ...request.query, gids })) + }, + }, + { + method: 'GET', + path: '/log/zone', + options: groupLogOptions(), + handler: async (request, h) => { + const gids = await groupScope(request) + return logResponse(h, await Audit.listZones({ ...request.query, gids })) + }, + }, + { + method: 'GET', + path: '/log/zone_record', + options: { + app: { permission: { resource: 'zone', action: 'read', idFrom: 'query.zid' } }, + validate: { query: validate.log.GET_req }, + response: { schema: validate.log.GET_res }, + tags: ['api'], + }, + handler: async (request, h) => logResponse( + h, + await Audit.listZoneRecords(request.query), + ), + }, + ]) +} + +function groupLogOptions() { + return { + app: { + permission: { + resource: 'log', + action: 'read', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, + validate: { query: validate.log.GET_req }, + response: { schema: validate.log.GET_res }, + tags: ['api'], + } +} + +async function groupScope(request) { + const gid = request.query.gid ?? request.auth.credentials.group.id + return request.query.include_subgroups === true ? Group.subgroupGids(gid) : [gid] +} + +function logResponse(h, result) { + return h.response({ + log: result.rows, + meta: { + api: meta.api, + msg: 'audit entries', + pagination: { + total: result.total, + filtered: result.filtered, + limit: result.limit, + offset: result.offset, + }, + }, + }).code(200) +} + +export default LogRoutes + +export { LogRoutes } diff --git a/routes/zone.js b/routes/zone.js index eccb016..e2efad3 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -4,6 +4,7 @@ import Zone from '../lib/zone/index.js' import Group from '../lib/group/index.js' import Authz from '../lib/authz.js' import Mysql from '../lib/mysql.js' +import Audit from '../lib/audit.js' import { meta } from '../lib/util.js' const ZONE_PUT_FIELDS = new Set([ @@ -122,6 +123,7 @@ function ZoneRoutes(server) { const id = await Zone.create(request.payload) const zones = await Zone.get({ id }) + await Audit.logZone(request.auth.credentials.user, 'added', zones[0]) return h .response({ @@ -169,7 +171,14 @@ function ZoneRoutes(server) { ) await Zone.put({ id, ...payload }) - const updated = await Zone.get({ id }) + let updated = await Zone.get({ id }) + if (updated.length === 0) updated = await Zone.get({ id, deleted: true }) + await Audit.logZone( + request.auth.credentials.user, + zoneAuditAction(zones[0], payload), + updated[0], + zones[0], + ) return h.response({ zone: updated, meta: { api: meta.api, msg: `the zone was updated` } }).code(200) }, }, @@ -239,6 +248,7 @@ function ZoneRoutes(server) { id: zones[0].id, deleted: 1, }) + await Audit.logZone(request.auth.credentials.user, 'deleted', zones[0]) return h .response({ @@ -254,6 +264,13 @@ function ZoneRoutes(server) { ]) } +function zoneAuditAction(previous, payload) { + if (payload.deleted === true && previous.deleted !== true) return 'deleted' + if (payload.deleted === false && previous.deleted === true) return 'recovered' + if (payload.gid !== undefined && payload.gid !== previous.gid) return 'moved' + return 'modified' +} + export default ZoneRoutes export { Zone, ZoneRoutes } diff --git a/routes/zone.test.js b/routes/zone.test.js index d16e644..88725ee 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -116,6 +116,14 @@ describe('zone routes', () => { // console.log(res.result) assert.equal(res.statusCode, 201) assert.ok(res.result.zone[0].gid) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone?gid=${case2Id}&search=route2.example.com.`, + headers: auth.headers, + }) + assert.equal(log.statusCode, 200) + assert.equal(log.result.log[0].action, 'added') }) it(`GET /zone/${case2Id}`, async () => { @@ -137,6 +145,24 @@ describe('zone routes', () => { }) // console.log(res.result) assert.equal(res.statusCode, 200) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone?gid=${case2Id}&search=route2.example.com.`, + headers: auth.headers, + }) + assert.equal(log.statusCode, 200) + assert.equal(log.result.log[0].action, 'deleted') + }) + + it('GET /log/global includes subgroup activity', async () => { + const res = await server.inject({ + method: 'GET', + url: `/log/global?gid=${groupCase.id}&include_subgroups=true&search=route2.example.com.`, + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + assert.ok(res.result.log.some((row) => row.title === 'route2.example.com.')) }) it(`DELETE /zone/${case2Id}`, async () => { diff --git a/routes/zone_record.js b/routes/zone_record.js index 6336709..087dd1b 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -3,6 +3,7 @@ import validate from '@nictool/validate' import ZoneRecord from '../lib/zone_record/index.js' import Zone from '../lib/zone/index.js' import Authz from '../lib/authz.js' +import Audit from '../lib/audit.js' import { meta } from '../lib/util.js' async function zoneRecordResponseFailAction(request, h, err) { @@ -123,6 +124,13 @@ function ZoneRecordRoutes(server) { const id = await ZoneRecord.create(request.payload) const zrs = await ZoneRecord.get({ id }) + const zones = await Zone.get({ id: zrs[0].zid }) + await Audit.logZoneRecord( + request.auth.credentials.user, + 'added', + zrs[0], + zones[0], + ) return h .response({ @@ -166,6 +174,14 @@ function ZoneRecordRoutes(server) { await ZoneRecord.put({ id, ...request.payload }) const updated = await ZoneRecord.get({ id }) + const zones = await Zone.get({ id: updated[0].zid }) + await Audit.logZoneRecord( + request.auth.credentials.user, + 'modified', + updated[0], + zones[0], + zrs[0], + ) return h .response({ zone_record: updated, @@ -208,6 +224,13 @@ function ZoneRecordRoutes(server) { id: zrs[0].id, deleted: 1, }) + const zones = await Zone.get({ id: zrs[0].zid }) + await Audit.logZoneRecord( + request.auth.credentials.user, + 'deleted', + zrs[0], + zones[0], + ) const deletedZrs = await ZoneRecord.get({ id: zrs[0].id, diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index 3b049b5..dd123e2 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -183,6 +183,15 @@ describe('zone_record routes', () => { assert.ok(Array.isArray(res.result.zone_record)) assert.equal(res.result.zone_record[0].id, testZoneRecordId) assert.equal(res.result.zone_record[0].deleted, true) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${testZoneId}&search=www.route-zr-delete`, + headers: auth.headers, + }) + assert.equal(log.statusCode, 200) + assert.equal(log.result.log[0].action, 'deleted') + assert.equal(log.result.log[0].owner, testZoneRecord.owner) }) it(`GET /zone_record/${testZoneRecordId} hides deleted by default`, async () => { From 6cf40d8a39766c9cf4a6f36c66d7037c42b434ef Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:02:48 +0100 Subject: [PATCH 05/48] zone: reject duplicate names --- routes/zone.js | 12 ++++++++++++ routes/zone.test.js | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/routes/zone.js b/routes/zone.js index e2efad3..ef7190e 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -120,6 +120,18 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { + const bare = request.payload.zone.replace(/\.$/, '') + const spellings = [...new Set([bare, `${bare}.`])] + const existing = await Promise.all(spellings.map((zone) => Zone.get({ zone }))) + if (existing.some((zones) => zones.length > 0)) { + return h + .response({ + zone: [], + meta: { api: meta.api, msg: `Zone is already taken` }, + }) + .code(409) + } + const id = await Zone.create(request.payload) const zones = await Zone.get({ id }) diff --git a/routes/zone.test.js b/routes/zone.test.js index 88725ee..62f88f9 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -13,6 +13,7 @@ import nsCase from './test/zone.json' with { type: 'json' } let server let case2Id = 4094 +const duplicateId = 4092 const subGroup = { id: 4090, parent_gid: groupCase.id, name: 'sub.route.example.com' } const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.example.com.' } @@ -20,6 +21,7 @@ const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.exampl before(async () => { await Zone.destroy({ id: nsCase.id }) await Zone.destroy({ id: case2Id }) + await Zone.destroy({ id: duplicateId }) await Zone.destroy({ id: subZone.id }) // Destroy the subgroup before recreating it: a lingering row would make // Group.create early-return and skip addToSubgroups, leaving the @@ -38,6 +40,7 @@ before(async () => { }) after(async () => { + await Zone.destroy({ id: duplicateId }) await Zone.destroy({ id: subZone.id }) await Group.destroy({ id: subGroup.id }) await Group.destroy({ id: case2Id }) @@ -81,6 +84,20 @@ describe('zone routes', () => { assert.ok(res.result.zone.some((z) => z.zone === nsCase.zone)) }) + it('POST /zone rejects an active duplicate name', async () => { + const duplicate = { ...nsCase, id: duplicateId, zone: `${nsCase.zone}.` } + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: duplicate, + }) + + assert.equal(res.statusCode, 409) + assert.deepEqual(res.result.zone, []) + assert.match(res.result.meta.msg, /already taken/) + }) + it(`PUT /zone/${nsCase.id} moves it to another group`, async () => { const moved = await server.inject({ method: 'PUT', From ab4286a95bc5647301c5517f7367c0ed9e0a7866 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:16:48 +0100 Subject: [PATCH 06/48] test: serialize mysql suites --- test/backends/mysql.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh index fdcaf47..8b994f5 100755 --- a/test/backends/mysql.sh +++ b/test/backends/mysql.sh @@ -23,5 +23,5 @@ test_files() { run_tests() { # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --test-reporter=spec $(test_files) + $NODE --test --test-concurrency=1 --test-reporter=spec $(test_files) } From 3a3f85d9b8fa829a404a3581023db8bcb6b3ce35 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:03:10 +0100 Subject: [PATCH 07/48] stores: audit, authz, delegation behind stores New subsystems reached for Mysql directly; route them through lib//store/ with base contracts, mysql implementations, and json file stores so a file-store deployment gets the whole api. Group moves run on a dedicated transaction connection instead of the shared one, whose reads could not see their own uncommitted writes. List endpoints gain an operator-tunable page ceiling (http.list_limit_max, default 1000). lib/store-access.test.js fails on any mysql import outside store modules. --- lib/audit.test.js | 2 +- lib/audit/index.js | 18 ++ lib/audit/store/base.js | 124 ++++++++++ lib/audit/store/file.js | 161 +++++++++++++ lib/{audit.js => audit/store/mysql.js} | 111 +++------ lib/authz-plugin.js | 22 +- lib/authz.test.js | 4 +- lib/authz/index.js | 18 ++ lib/{authz.js => authz/store/base.js} | 223 ++++-------------- lib/authz/store/file.js | 181 ++++++++++++++ lib/authz/store/mysql.js | 179 ++++++++++++++ lib/config.js | 3 + lib/delegation/index.js | 18 ++ lib/delegation/store/base.js | 58 +++++ lib/delegation/store/file.js | 171 ++++++++++++++ .../store/mysql.js} | 47 +--- lib/group/store/mysql.js | 42 ++-- lib/mysql.js | 28 +++ lib/page.js | 13 + lib/page.test.js | 31 +++ lib/store-access.test.js | 42 ++++ lib/zone/store/base.js | 5 + lib/zone/store/file.js | 18 ++ lib/zone/store/mysql.js | 12 + routes/authz.test.js | 2 +- routes/delegation.js | 4 +- routes/group.js | 2 +- routes/log.js | 2 +- routes/permission.js | 2 +- routes/user.js | 5 +- routes/zone.js | 18 +- routes/zone_record.js | 7 +- 32 files changed, 1211 insertions(+), 362 deletions(-) create mode 100644 lib/audit/index.js create mode 100644 lib/audit/store/base.js create mode 100644 lib/audit/store/file.js rename lib/{audit.js => audit/store/mysql.js} (66%) create mode 100644 lib/authz/index.js rename lib/{authz.js => authz/store/base.js} (64%) create mode 100644 lib/authz/store/file.js create mode 100644 lib/authz/store/mysql.js create mode 100644 lib/delegation/index.js create mode 100644 lib/delegation/store/base.js create mode 100644 lib/delegation/store/file.js rename lib/{delegation.js => delegation/store/mysql.js} (86%) create mode 100644 lib/page.js create mode 100644 lib/page.test.js create mode 100644 lib/store-access.test.js diff --git a/lib/audit.test.js b/lib/audit.test.js index 2dd6d71..08da7bc 100644 --- a/lib/audit.test.js +++ b/lib/audit.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { after, before, describe, it } from 'node:test' -import Audit from './audit.js' +import Audit from './audit/index.js' import Group from './group/index.js' import Mysql from './mysql.js' import User from './user/index.js' diff --git a/lib/audit/index.js b/lib/audit/index.js new file mode 100644 index 0000000..fb168fb --- /dev/null +++ b/lib/audit/index.js @@ -0,0 +1,18 @@ +import { storeType } from '../config.js' + +const type = storeType() + +let RepoClass +switch (type) { + case 'json': + case 'toml': + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default + break + default: + throw new Error(`audit: no store implementation for type "${type}"`) +} + +export default new RepoClass() diff --git a/lib/audit/store/base.js b/lib/audit/store/base.js new file mode 100644 index 0000000..cf31201 --- /dev/null +++ b/lib/audit/store/base.js @@ -0,0 +1,124 @@ +/** + * Audit domain class – pure contract and cross-cutting logic. + * + * Has zero knowledge of how audit entries are persisted. All audit repository + * classes must extend this class and implement the repo contract. + * + * Repo contract: + * insertZoneLog(detail) → logId + * insertZoneRecordLog(detail) → logId + * insertGlobalLog(entry) → void + * listGlobal(args) → { rows, total, filtered, limit, offset } + * listZones(args) → same shape + * listZoneRecords(args) → same shape + */ +const actionDescription = { + added: 'initial creation', + deleted: 'deleted', + modified: 'modified', + moved: 'moved', + recovered: 'recovered', +} + +class AuditBase { + async logZone(actor, action, zone, previous = {}) { + const timestamp = Math.floor(Date.now() / 1000) + const detail = compact({ + gid: zone.gid, + zid: zone.id, + uid: actor.id, + action, + timestamp, + zone: zone.zone, + mailaddr: zone.mailaddr, + description: zone.description, + refresh: zone.refresh, + retry: zone.retry, + expire: zone.expire, + ttl: zone.ttl, + minimum: zone.minimum, + serial: zone.serial, + }) + const logId = await this.insertZoneLog(detail) + await this.insertGlobalLog({ + uid: actor.id, + timestamp, + action, + object: 'zone', + objectId: zone.id, + logId, + title: zone.zone, + description: describe(action, 'zone', zone, previous), + }) + return logId + } + + async logZoneRecord(actor, action, record, zone, previous = {}) { + const timestamp = Math.floor(Date.now() / 1000) + const detail = compact({ + zid: record.zid, + zrid: record.id, + uid: actor.id, + action, + timestamp, + owner: record.owner, + ttl: record.ttl, + description: record.description, + type: record.type, + address: record.address, + weight: record.weight, + priority: record.priority, + other: record.other, + location: record.location, + }) + const logId = await this.insertZoneRecordLog(detail) + await this.insertGlobalLog({ + uid: actor.id, + timestamp, + action, + object: 'zone_record', + objectId: record.id, + logId, + title: record.owner, + description: describe(action, 'record', record, previous, zone), + }) + return logId + } + + async insertZoneLog(_detail) { + throw new Error('insertZoneLog() not implemented by this store') + } + + async insertZoneRecordLog(_detail) { + throw new Error('insertZoneRecordLog() not implemented by this store') + } + + async insertGlobalLog(_entry) { + throw new Error('insertGlobalLog() not implemented by this store') + } + + async listGlobal(_args) { + throw new Error('listGlobal() not implemented by this store') + } + + async listZones(_args) { + throw new Error('listZones() not implemented by this store') + } + + async listZoneRecords(_args) { + throw new Error('listZoneRecords() not implemented by this store') + } +} + +function compact(obj) { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) +} + +function describe(action, object, current, previous, zone) { + if (action === 'moved') return `moved from group ${previous.gid} to ${current.gid}` + if (action === 'deleted' && object === 'record') return `deleted record from ${zone.zone}` + if (action === 'recovered' && object === 'record') return `recovered ${current.type} record` + return `${actionDescription[action] ?? action} ${object}` +} + +export default AuditBase diff --git a/lib/audit/store/file.js b/lib/audit/store/file.js new file mode 100644 index 0000000..4e04d6b --- /dev/null +++ b/lib/audit/store/file.js @@ -0,0 +1,161 @@ +import FileStore from '../../store/file.js' +import { pageLimit } from '../../page.js' + +import AuditBase from './base.js' + +class AuditRepoFile extends AuditBase { + constructor() { + super() + this.zoneLog = new FileStore('zone_log') + this.recordLog = new FileStore('record_log') + this.globalLog = new FileStore('global_log') + } + + async insertZoneLog(detail) { + const rows = await this.zoneLog.load('zone_log') + const row = { id: nextId(rows), ...detail } + rows.push(row) + await this.zoneLog.save('zone_log', rows) + return row.id + } + + async insertZoneRecordLog(detail) { + const rows = await this.recordLog.load('record_log') + const row = { id: nextId(rows), ...detail } + rows.push(row) + await this.recordLog.save('record_log', rows) + return row.id + } + + async insertGlobalLog(entry) { + const rows = await this.globalLog.load('global_log') + rows.push({ + id: nextId(rows), + uid: entry.uid, + timestamp: entry.timestamp, + action: entry.action, + object: entry.object, + object_id: entry.objectId, + log_entry_id: entry.logId, + title: entry.title, + description: entry.description, + }) + await this.globalLog.save('global_log', rows) + } + + async listGlobal(args) { + const users = await loadUsers() + const groups = await loadGroups() + let rows = (await this.globalLog.load('global_log')).map((row) => { + const user = users.find((u) => u.id === row.uid) + return { + ...row, + gid: user?.gid, + group_name: groups.find((g) => g.id === user?.gid)?.name ?? '', + user: displayUser(user), + } + }) + + const gids = intValues(args.gids) + rows = rows.filter((row) => gids.includes(row.gid)) + if (Number.isInteger(args.uid)) rows = rows.filter((row) => row.uid === args.uid) + + return page(rows, args, { + searchKeys: ['user', 'action', 'object', 'title', 'description'], + sortMap: { + timestamp: 'timestamp', user: 'user', action: 'action', object: 'object', + title: 'title', description: 'description', group_name: 'group_name', + }, + }) + } + + async listZones(args) { + const users = await loadUsers() + const groups = await loadGroups() + const rows = (await this.zoneLog.load('zone_log')).map((row) => ({ + ...row, + group_name: groups.find((g) => g.id === row.gid)?.name ?? '', + user: displayUser(users.find((u) => u.id === row.uid)), + })) + + const gids = intValues(args.gids) + const scoped = rows.filter((row) => gids.includes(row.gid)) + + return page(scoped, args, { + searchKeys: ['zone', 'description', 'action', 'user', 'group_name'], + sortMap: { + timestamp: 'timestamp', user: 'user', action: 'action', zone: 'zone', + ttl: 'ttl', description: 'description', group_name: 'group_name', + }, + }) + } + + async listZoneRecords(args) { + const users = await loadUsers() + let rows = (await this.recordLog.load('record_log')).map((row) => ({ + ...row, + user: displayUser(users.find((u) => u.id === row.uid)), + })) + + rows = rows.filter((row) => row.zid === args.zid) + if (Number.isInteger(args.id)) rows = rows.filter((row) => row.id === args.id) + + return page(rows, args, { + searchKeys: ['owner', 'description', 'type', 'address', 'action', 'user'], + sortMap: { + timestamp: 'timestamp', user: 'user', action: 'action', owner: 'owner', + type: 'type', address: 'address', ttl: 'ttl', weight: 'weight', + description: 'description', + }, + }) + } +} + +async function loadUsers() { + return new FileStore('user').load('user') +} + +async function loadGroups() { + return new FileStore('group').load('group') +} + +function displayUser(user) { + if (!user) return '' + return `${user.first_name ?? ''} ${user.last_name ?? ''} (${user.username})`.trim() +} + +function intValues(values) { + return (Array.isArray(values) ? values : [values]).map(Number).filter(Number.isInteger) +} + +function nextId(rows) { + return rows.reduce((max, row) => Math.max(max, row.id ?? 0), 0) + 1 +} + +/** Search/sort/paginate in memory, mirroring the mysql listing behavior. */ +async function page(rows, args, { searchKeys, sortMap }) { + const limit = await pageLimit(args.limit, 50) + const offset = Number.isInteger(args.offset) ? args.offset : 0 + const total = rows.length + + const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' + if (search !== '') { + rows = rows.filter((row) => + searchKeys.some((key) => String(row[key] ?? '').toLowerCase().includes(search))) + } + + // LIKE treats % and _ as wildcards; here they match literally + const filteredCount = rows.length + + const sortBy = sortMap[args.sort_by] ?? sortMap.timestamp + const dir = args.sort_dir === 'asc' ? 1 : -1 + rows.sort((a, b) => { + if (a[sortBy] < b[sortBy]) return -dir + if (a[sortBy] > b[sortBy]) return dir + return (b.id ?? 0) - (a.id ?? 0) // stable pagination tiebreak, as in sql + }) + + return { rows: rows.slice(offset, offset + limit), total, filtered: filteredCount, limit, offset } +} + +export default AuditRepoFile diff --git a/lib/audit.js b/lib/audit/store/mysql.js similarity index 66% rename from lib/audit.js rename to lib/audit/store/mysql.js index bf10ab4..801d499 100644 --- a/lib/audit.js +++ b/lib/audit/store/mysql.js @@ -1,81 +1,20 @@ import * as RR from '@nictool/dns-resource-record' -import Mysql from './mysql.js' - -const actionDescription = { - added: 'initial creation', - deleted: 'deleted', - modified: 'modified', - moved: 'moved', - recovered: 'recovered', -} +import Mysql from '../../mysql.js' +import { pageLimit } from '../../page.js' -class Audit { - async logZone(actor, action, zone, previous = {}) { - const timestamp = Math.floor(Date.now() / 1000) - const detail = compact({ - nt_group_id: zone.gid, - nt_zone_id: zone.id, - nt_user_id: actor.id, - action, - timestamp, - zone: zone.zone, - mailaddr: zone.mailaddr, - description: zone.description, - refresh: zone.refresh, - retry: zone.retry, - expire: zone.expire, - ttl: zone.ttl, - minimum: zone.minimum, - serial: zone.serial, - }) - const logId = await Mysql.execute(...Mysql.insert('nt_zone_log', detail)) - await this.logGlobal({ - uid: actor.id, - timestamp, - action, - object: 'zone', - objectId: zone.id, - logId, - title: zone.zone, - description: describe(action, 'zone', zone, previous), - }) - return logId +import AuditBase from './base.js' + +class AuditRepoMysql extends AuditBase { + async insertZoneLog(detail) { + return Mysql.execute(...Mysql.insert('nt_zone_log', mapZone(detail))) } - async logZoneRecord(actor, action, record, zone, previous = {}) { - const timestamp = Math.floor(Date.now() / 1000) - const detail = compact({ - nt_zone_id: record.zid, - nt_zone_record_id: record.id, - nt_user_id: actor.id, - action, - timestamp, - name: record.owner, - ttl: record.ttl, - description: record.description, - type_id: RR.typeMap[record.type], - address: record.address, - weight: record.weight, - priority: record.priority, - other: record.other, - location: record.location, - }) - const logId = await Mysql.execute(...Mysql.insert('nt_zone_record_log', detail)) - await this.logGlobal({ - uid: actor.id, - timestamp, - action, - object: 'zone_record', - objectId: record.id, - logId, - title: record.owner, - description: describe(action, 'record', record, previous, zone), - }) - return logId + async insertZoneRecordLog(detail) { + return Mysql.execute(...Mysql.insert('nt_zone_record_log', mapRecord(detail))) } - async logGlobal({ uid, timestamp, action, object, objectId, logId, title, description }) { + async insertGlobalLog({ uid, timestamp, action, object, objectId, logId, title, description }) { return Mysql.execute(...Mysql.insert('nt_user_global_log', { nt_user_id: uid, timestamp, @@ -150,6 +89,7 @@ class Audit { CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, from: `FROM nt_zone_record_log rl JOIN nt_user u ON u.nt_user_id = rl.nt_user_id + JOIN nt_group g ON g.nt_group_id = u.nt_group_id JOIN resource_record_type rt ON rt.id = rl.type_id`, where, params, @@ -166,8 +106,22 @@ class Audit { } } +function mapZone(detail) { + const { gid, zid, uid, ...rest } = detail + return { nt_group_id: gid, nt_zone_id: zid, nt_user_id: uid, ...rest } +} + +function mapRecord(detail) { + const { owner, ...rest } = detail + const detailOut = { name: owner, ...rest } + if (detailOut.type !== undefined) detailOut.type_id = RR.typeMap[detailOut.type] + delete detailOut.type + const { zid, zrid, uid, ...cols } = detailOut + return { nt_zone_id: zid, nt_zone_record_id: zrid, nt_user_id: uid, ...cols } +} + async function listRows({ select, from, where, params, searchColumns, sortMap, args }) { - const limit = Number.isInteger(args.limit) ? args.limit : 50 + const limit = await pageLimit(args.limit, 50) const offset = Number.isInteger(args.offset) ? args.offset : 0 const total = await countRows(from, where, params) @@ -206,15 +160,4 @@ function groupScope(column, gids) { } } -function compact(obj) { - return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) -} - -function describe(action, object, current, previous, zone) { - if (action === 'moved') return `moved from group ${previous.gid} to ${current.gid}` - if (action === 'deleted' && object === 'record') return `deleted record from ${zone.zone}` - if (action === 'recovered' && object === 'record') return `recovered ${current.type} record` - return `${actionDescription[action] ?? action} ${object}` -} - -export default new Audit() +export default AuditRepoMysql diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index ba3da25..a255f94 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -1,5 +1,6 @@ -import Authz from './authz.js' -import Mysql from './mysql.js' +import Authz from './authz/index.js' +import Zone from './zone/index.js' +import ZoneRecord from './zone_record/index.js' const TYPE_TO_RESOURCE = { ZONE: 'zone', @@ -244,13 +245,9 @@ async function resolveTargetGroup(request, resource) { if (resource === 'zonerecord') { const zid = request.payload?.zid ?? request.payload?.nt_zone_id if (zid) { - const rows = await Mysql.execute( - 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ? AND deleted = 0', - [zid], - ) - if (rows.length > 0) { - return { gid: rows[0].nt_group_id, zid: Number(zid) } - } + const zones = await Zone.get({ id: Number(zid) }) + const zone = zones.find((z) => z.deleted !== true) + if (zone) return { gid: zone.gid, zid: Number(zid) } } return null } @@ -265,11 +262,8 @@ async function resolveTargetGroup(request, resource) { async function changesTarget(request, resource, action, objectId) { if (resource !== 'zonerecord' || action !== 'write') return true - const rows = await Mysql.execute( - 'SELECT nt_zone_id AS zid FROM nt_zone_record WHERE nt_zone_record_id = ?', - [objectId], - ) - return rows.length === 0 || rows[0].zid !== Number(request.payload.zid) + const records = await ZoneRecord.get({ id: objectId }) + return records.length === 0 || records[0].zid !== Number(request.payload.zid) } export default authzPlugin diff --git a/lib/authz.test.js b/lib/authz.test.js index a1fc345..a324742 100644 --- a/lib/authz.test.js +++ b/lib/authz.test.js @@ -7,8 +7,8 @@ import Zone from './zone/index.js' import ZoneRecord from './zone_record/index.js' import Nameserver from './nameserver/index.js' import Permission from './permission/index.js' -import Delegation from './delegation.js' -import Authz from './authz.js' +import Delegation from './delegation/index.js' +import Authz from './authz/index.js' import Mysql from './mysql.js' const G_ROOT = { diff --git a/lib/authz/index.js b/lib/authz/index.js new file mode 100644 index 0000000..891a470 --- /dev/null +++ b/lib/authz/index.js @@ -0,0 +1,18 @@ +import { storeType } from '../config.js' + +const type = storeType() + +let RepoClass +switch (type) { + case 'json': + case 'toml': + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default + break + default: + throw new Error(`authz: no store implementation for type "${type}"`) +} + +export default new RepoClass() diff --git a/lib/authz.js b/lib/authz/store/base.js similarity index 64% rename from lib/authz.js rename to lib/authz/store/base.js index 98aad4f..a76190b 100644 --- a/lib/authz.js +++ b/lib/authz/store/base.js @@ -1,15 +1,24 @@ -import Mysql from './mysql.js' -import Permission from './permission/index.js' - -const RESOURCE_QUERIES = { - zone: 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?', - zonerecord: `SELECT z.nt_group_id FROM nt_zone_record r - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - WHERE r.nt_zone_record_id = ?`, - user: 'SELECT nt_group_id FROM nt_user WHERE nt_user_id = ?', - group: 'SELECT parent_group_id AS nt_group_id FROM nt_group WHERE nt_group_id = ?', - nameserver: 'SELECT nt_group_id FROM nt_nameserver WHERE nt_nameserver_id = ?', -} +/** + * Authz domain class – pure policy and cross-cutting logic. + * + * Has zero knowledge of how users, groups, delegations, or sessions are + * persisted. All authz repository classes must extend this class and implement + * the repo contract. + * + * Repo contract: + * objectGroupId(resource, objectId) → gid | null + * isInGroupTree(userGid, targetGid) → boolean + * isActiveGroup(gid) → boolean + * isActiveObject(resource, objectId) → boolean + * getDirectDelegateAccess(gid, oid, resource) → row | null + * getDelegatedZoneIds(groupIds) → number[] + * delegatedRecordIdsInZone(gid, zid) → number[] ([] when no access) + * zonePseudoDelegation(gid, zid) → row | null + * zoneDelegationForRecord(gid, zrid) → row | null + * liveSessionGroup(userId, sessionId, oldestSec) → gid | null + * permissionRecord(permissionId) → { uid, gid, target_gid } | null + */ +import Permission from '../../permission/index.js' const DELEGATE_TYPE = { zone: 'ZONE', @@ -37,7 +46,7 @@ const ACTION_PERMISSION = { const SESSION_MAX_AGE_SEC = 14400 -class Authz { +class AuthzBase { async checkPermission(credentials, resource, action, objectId, opts) { const perm = await Permission.getEffective(credentials.user.id) if (!perm) return deny(`No permissions found`) @@ -152,48 +161,6 @@ class Authz { ) } - async getObjectGroupId(resource, objectId) { - const query = RESOURCE_QUERIES[resource] - if (!query) return null - - const rows = await Mysql.execute(query, [objectId]) - if (rows.length === 0) return null - - let gid = rows[0].nt_group_id - if (resource === 'group' && (gid === 0 || gid === null)) gid = 1 - return gid - } - - async isInGroupTree(userGroupId, targetGroupId) { - if (userGroupId === targetGroupId) return true - - const rows = await Mysql.execute( - `SELECT COUNT(*) AS count FROM nt_group_subgroups - WHERE nt_group_id = ? AND nt_subgroup_id = ?`, - [userGroupId, targetGroupId], - ) - return rows[0].count > 0 - } - - async isActiveGroup(groupId) { - const rows = await Mysql.execute( - 'SELECT 1 FROM nt_group WHERE nt_group_id = ? AND deleted = 0', - [groupId], - ) - return rows.length > 0 - } - - async isActiveObject(resource, objectId) { - const table = delegateTable(resource) - const idColumn = delegateIdColumn(resource)?.slice(2) - if (!table || !idColumn) return false - const rows = await Mysql.execute( - `SELECT 1 FROM ${table} WHERE ${idColumn} = ? AND deleted = 0`, - [objectId], - ) - return rows.length > 0 - } - async getDelegateAccess(groupId, objectId, resource) { const type = DELEGATE_TYPE[resource] if (!type) return null @@ -202,85 +169,26 @@ class Authz { if (direct) return direct if (resource === 'zonerecord') { - return this.getZoneRecordPseudoDelegation(groupId, objectId) + return this.zoneDelegationForRecord(groupId, objectId) } if (resource === 'zone') { - return this.getZonePseudoDelegation(groupId, objectId) + return this.zonePseudoDelegation(groupId, objectId) } return null } - async getDirectDelegateAccess(groupId, objectId, resource) { - const type = DELEGATE_TYPE[resource] - if (!type) return null - const rows = await Mysql.execute( - `SELECT d.* FROM nt_delegate d - JOIN ${delegateTable(resource)} o ON ${delegateIdColumn(resource)} = d.nt_object_id - WHERE d.nt_group_id = ? AND d.nt_object_id = ? AND d.nt_object_type = ? - AND d.deleted = 0 AND o.deleted = 0`, - [groupId, objectId, type], - ) - return rows.length > 0 ? rows[0] : null - } - - async getDelegatedZoneIds(groupIds) { - const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]) - .map(Number) - .filter(Number.isInteger) - if (gids.length === 0) return [] - const placeholders = gids.map(() => '?').join(', ') - const rows = await Mysql.execute( - `SELECT d.nt_object_id AS id - FROM nt_delegate d - JOIN nt_zone z ON z.nt_zone_id = d.nt_object_id - WHERE d.nt_group_id IN (${placeholders}) - AND d.nt_object_type = 'ZONE' AND d.deleted = 0 AND z.deleted = 0 - UNION - SELECT r.nt_zone_id AS id - FROM nt_delegate d - JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - WHERE d.nt_group_id IN (${placeholders}) - AND d.nt_object_type = 'ZONERECORD' - AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, - [...gids, ...gids], - ) - return rows.map((row) => row.id) - } - async getZoneRecordReadScope(groupId, zoneId) { const objectGroupId = await this.getObjectGroupId('zone', zoneId) if (objectGroupId === null) return [] if (await this.isInGroupTree(groupId, objectGroupId)) return null if (await this.getDirectDelegateAccess(groupId, zoneId, 'zone')) return null - - const rows = await Mysql.execute( - `SELECT r.nt_zone_record_id AS id - FROM nt_delegate d - JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - WHERE d.nt_group_id = ? AND r.nt_zone_id = ? - AND d.nt_object_type = 'ZONERECORD' - AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, - [groupId, zoneId], - ) - return rows.map((row) => row.id) + return this.delegatedRecordIdsInZone(groupId, zoneId) } // v2 grants read on a zone to any group holding a delegation on one of its // records. Every permission is 0, so only the read fast-paths above accept it. - async getZonePseudoDelegation(groupId, zoneId) { - const rows = await Mysql.execute( - `SELECT 1 FROM nt_delegate d - JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - WHERE d.nt_group_id = ? - AND z.nt_zone_id = ? - AND d.nt_object_type = 'ZONERECORD' - AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0 - LIMIT 1`, - [groupId, zoneId], - ) + async zonePseudoDelegation(groupId, zoneId) { + const rows = await this.delegatedRecordIdsInZone(groupId, zoneId) if (rows.length === 0) return null return { pseudo: 1, @@ -292,20 +200,6 @@ class Authz { } } - async getZoneRecordPseudoDelegation(groupId, zoneRecordId) { - const rows = await Mysql.execute( - `SELECT d.*, 1 AS pseudo FROM nt_delegate d - JOIN nt_zone_record r ON r.nt_zone_id = d.nt_object_id - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - WHERE d.nt_group_id = ? - AND r.nt_zone_record_id = ? - AND d.nt_object_type = 'ZONE' - AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, - [groupId, zoneRecordId], - ) - return rows.length > 0 ? rows[0] : null - } - capPermissions(userPerm, targetPerms, existingPerm) { if (!targetPerms || !userPerm) return targetPerms @@ -389,56 +283,41 @@ class Authz { async getCurrentCredentials(credentials) { // Match the JWT's maximum token age when checking server-side revocation. const oldest = Math.floor(Date.now() / 1000) - SESSION_MAX_AGE_SEC - const rows = await Mysql.execute( - `SELECT u.nt_group_id AS gid - FROM nt_user u - JOIN nt_group g ON g.nt_group_id = u.nt_group_id - JOIN nt_user_session s ON s.nt_user_id = u.nt_user_id - WHERE u.nt_user_id = ? AND s.nt_user_session_id = ? - AND u.deleted = 0 AND g.deleted = 0 - AND s.last_access >= ?`, - [credentials.user.id, credentials.session.id, oldest], + const gid = await this.liveSessionGroup( + credentials.user.id, credentials.session.id, oldest, ) - if (rows.length === 0) return null + if (gid === null) return null return { ...credentials, - group: { ...credentials.group, id: rows[0].gid }, + group: { ...credentials.group, id: gid }, } } async checkPermissionRecord(credentials, action, permissionId) { - const rows = await Mysql.execute( - `SELECT NULLIF(p.nt_user_id, 0) AS uid, - NULLIF(p.nt_group_id, 0) AS gid, - COALESCE(NULLIF(p.nt_group_id, 0), u.nt_group_id) AS target_gid - FROM nt_perm p - LEFT JOIN nt_user u ON u.nt_user_id = p.nt_user_id - WHERE p.nt_perm_id = ?`, - [permissionId], - ) - if (rows.length === 0 || rows[0].target_gid === null) { + const record = await this.permissionRecord(permissionId) + if (!record || record.target_gid === null) { return deny(`No Access Allowed to that permission (${permissionId})`) } if (action === 'read') { - return await this.isInGroupTree(credentials.group.id, rows[0].target_gid) + return await this.isInGroupTree(credentials.group.id, record.target_gid) ? allow() : deny(`No Access Allowed to that permission (${permissionId})`) } - if (rows[0].uid !== null) { - if (rows[0].uid === credentials.user.id) { + if (record.uid !== null) { + if (record.uid === credentials.user.id) { return deny(`Not allowed to modify your own permissions`) } - if (!await this.isActiveObject('user', rows[0].uid)) { + if (!await this.isActiveObject('user', record.uid)) { return deny(`Cannot modify permissions for a deleted user`) } - return this.checkPermission(credentials, 'user', 'write', rows[0].uid) + return this.checkPermission(credentials, 'user', 'write', record.uid) } - if (!await this.isActiveGroup(rows[0].gid)) { + if (!await this.isActiveGroup(record.gid)) { return deny(`Cannot modify permissions for a deleted group`) } - return this.checkPermission(credentials, 'group', 'write', rows[0].gid) + return this.checkPermission(credentials, 'group', 'write', record.gid) } async checkPermissionTarget(credentials, payload) { @@ -471,26 +350,6 @@ class Authz { } } -function delegateTable(resource) { - return { - zone: 'nt_zone', - zonerecord: 'nt_zone_record', - nameserver: 'nt_nameserver', - group: 'nt_group', - user: 'nt_user', - }[resource] -} - -function delegateIdColumn(resource) { - return { - zone: 'o.nt_zone_id', - zonerecord: 'o.nt_zone_record_id', - nameserver: 'o.nt_nameserver_id', - group: 'o.nt_group_id', - user: 'o.nt_user_id', - }[resource] -} - function allow(extra = {}) { return { allowed: true, ...extra } } @@ -508,4 +367,4 @@ function capUsableNameservers(requested, allowed, existing) { return result } -export default new Authz() +export default AuthzBase diff --git a/lib/authz/store/file.js b/lib/authz/store/file.js new file mode 100644 index 0000000..545f63d --- /dev/null +++ b/lib/authz/store/file.js @@ -0,0 +1,181 @@ +import Delegation from '../../delegation/index.js' +import FileStore from '../../store/file.js' + +import AuthzBase from './base.js' + +// object lookups by authz resource: which file holds the rows and which key +// carries the group +const RESOURCES = { + zone: { file: 'zone' }, + zonerecord: { file: 'zone_record' }, + user: { file: 'user' }, + nameserver: { file: 'nameserver' }, + group: { file: 'group', selfOwned: true }, +} + +class AuthzRepoFile extends AuthzBase { + async _rows(file) { + return new FileStore(file).load(file) + } + + async _object(resource, objectId) { + const meta = RESOURCES[resource] + if (!meta) return null + const rows = await this._rows(meta.file) + return rows.find((r) => r.id === objectId) ?? null + } + + async getObjectGroupId(resource, objectId) { + if (resource === 'zonerecord') { + const record = await this._object('zonerecord', objectId) + if (!record || record.deleted === true) return null + return this.getObjectGroupId('zone', record.zid) + } + const row = await this._object(resource, objectId) + if (!row || row.deleted === true) return null + if (resource === 'group') { + // the root group has no parent; v2 routes its objects to group 1 + return row.parent_gid ?? row.gid ?? 1 + } + return row.gid ?? null + } + + async isInGroupTree(userGroupId, targetGroupId) { + if (userGroupId === targetGroupId) return true + const groups = await this._rows('group') + const queue = [userGroupId] + const seen = new Set(queue) + while (queue.length > 0) { + const current = queue.shift() + for (const g of groups) { + if (g.parent_gid === current && !seen.has(g.id)) { + if (g.id === targetGroupId) return true + seen.add(g.id) + queue.push(g.id) + } + } + } + return false + } + + async isActiveGroup(groupId) { + const group = await this._object('group', groupId) + return group != null && group.deleted !== true + } + + async isActiveObject(resource, objectId) { + const row = await this._object(resource, objectId) + return row != null && row.deleted !== true + } + + async getDirectDelegateAccess(groupId, objectId, resource) { + const type = { zone: 'ZONE', zonerecord: 'ZONERECORD', nameserver: 'NAMESERVER', group: 'GROUP' }[ + resource + ] + if (!type) return null + + const delegations = await Delegation.getDelegates(objectId, type, groupId) + if (delegations.length === 0) return null + if (!await this.isActiveObject(resource, objectId)) return null + const row = delegations[0] + // mysql returns raw nt_delegate columns here; shape them to match + return { + nt_group_id: row.nt_group_id, + nt_object_id: row.nt_object_id, + nt_object_type: row.nt_object_type, + perm_write: row.delegate_write, + perm_delete: row.delegate_delete, + perm_delegate: row.delegate_delegate, + zone_perm_add_records: row.delegate_add_records, + zone_perm_delete_records: row.delegate_delete_records, + } + } + + async getDelegatedZoneIds(groupIds) { + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]) + .map(Number) + .filter(Number.isInteger) + + const zones = await this._rows('zone') + const records = await this._rows('zone_record') + const zoneById = new Map(zones.map((z) => [z.id, z])) + + const ids = [] + for (const gid of gids) { + for (const d of await Delegation.getDelegated(gid, 'ZONE')) { + const zone = zoneById.get(d.nt_object_id) + if (zone?.deleted !== true) ids.push(d.nt_object_id) + } + for (const d of await Delegation.getDelegated(gid, 'ZONERECORD')) { + const record = records.find((r) => r.id === d.nt_object_id) + const zone = zoneById.get(record?.zid) + if (record?.deleted !== true && zone?.deleted !== true) ids.push(record.zid) + } + } + return [...new Set(ids)] + } + + async delegatedRecordIdsInZone(groupId, zoneId) { + const delegations = await Delegation.getDelegated(groupId, 'ZONERECORD') + const records = await this._rows('zone_record') + const zones = await this._rows('zone') + + const zone = zones.find((z) => z.id === zoneId) + if (!zone || zone.deleted === true) return [] + + return delegations + .map((d) => records.find((r) => r.id === d.nt_object_id)) + .filter((r) => r && r.deleted !== true && r.zid === zoneId) + .map((r) => r.id) + } + + async zoneDelegationForRecord(groupId, zoneRecordId) { + const records = await this._rows('zone_record') + const record = records.find((r) => r.id === zoneRecordId && r.deleted !== true) + if (!record) return null + + const direct = await this.getDirectDelegateAccess(groupId, record.zid, 'zone') + return direct ? { ...direct, pseudo: 1 } : null + } + + async liveSessionGroup(userId, sessionId, oldestSec) { + const users = await this._rows('user') + const groups = await this._rows('group') + const sessions = await this._rows('session') + + const user = users.find((u) => u.id === userId && u.deleted !== true) + if (!user) return null + const group = groups.find((g) => g.id === user.gid && g.deleted !== true) + if (!group) return null + + // file stores keep no last_access per session; presence is the best + // available signal that the session is still live + const session = sessions.find( + (s) => s.user_id === userId && s.id === sessionId && (s.last_access ?? Infinity) >= oldestSec, + ) + return session ? user.gid : null + } + + async permissionRecord(permissionId) { + // permissions ride on user and group rows in the file store + const users = await this._rows('user') + const groups = await this._rows('group') + + for (const u of users) { + for (const p of u.permissions ?? []) { + if (p.permissionId === permissionId || p.id === permissionId) { + return { uid: u.id, gid: null, target_gid: u.gid } + } + } + } + for (const g of groups) { + const p = g.permissions + if (p && (p.permissionId === permissionId || p.id === permissionId)) { + return { uid: null, gid: g.id, target_gid: g.id } + } + } + return null + } +} + +export default AuthzRepoFile diff --git a/lib/authz/store/mysql.js b/lib/authz/store/mysql.js new file mode 100644 index 0000000..409fa2c --- /dev/null +++ b/lib/authz/store/mysql.js @@ -0,0 +1,179 @@ +import Mysql from '../../mysql.js' + +import AuthzBase from './base.js' + +const RESOURCE_QUERIES = { + zone: 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?', + zonerecord: `SELECT z.nt_group_id FROM nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE r.nt_zone_record_id = ?`, + user: 'SELECT nt_group_id FROM nt_user WHERE nt_user_id = ?', + group: 'SELECT parent_group_id AS nt_group_id FROM nt_group WHERE nt_group_id = ?', + nameserver: 'SELECT nt_group_id FROM nt_nameserver WHERE nt_nameserver_id = ?', +} + +const DELEGATE_TYPE = { + zone: 'ZONE', + zonerecord: 'ZONERECORD', + nameserver: 'NAMESERVER', + group: 'GROUP', +} + +function delegateTable(resource) { + return { + zone: 'nt_zone', + zonerecord: 'nt_zone_record', + nameserver: 'nt_nameserver', + group: 'nt_group', + user: 'nt_user', + }[resource] +} + +function delegateIdColumn(resource) { + return { + zone: 'o.nt_zone_id', + zonerecord: 'o.nt_zone_record_id', + nameserver: 'o.nt_nameserver_id', + group: 'o.nt_group_id', + user: 'o.nt_user_id', + }[resource] +} + +class AuthzRepoMysql extends AuthzBase { + async getObjectGroupId(resource, objectId) { + const query = RESOURCE_QUERIES[resource] + if (!query) return null + + const rows = await Mysql.execute(query, [objectId]) + if (rows.length === 0) return null + + let gid = rows[0].nt_group_id + if (resource === 'group' && (gid === 0 || gid === null)) gid = 1 + return gid + } + + async isInGroupTree(userGroupId, targetGroupId) { + if (userGroupId === targetGroupId) return true + + const rows = await Mysql.execute( + `SELECT COUNT(*) AS count FROM nt_group_subgroups + WHERE nt_group_id = ? AND nt_subgroup_id = ?`, + [userGroupId, targetGroupId], + ) + return rows[0].count > 0 + } + + async isActiveGroup(groupId) { + const rows = await Mysql.execute( + 'SELECT 1 FROM nt_group WHERE nt_group_id = ? AND deleted = 0', + [groupId], + ) + return rows.length > 0 + } + + async isActiveObject(resource, objectId) { + const table = delegateTable(resource) + const idColumn = delegateIdColumn(resource)?.slice(2) + if (!table || !idColumn) return false + const rows = await Mysql.execute( + `SELECT 1 FROM ${table} WHERE ${idColumn} = ? AND deleted = 0`, + [objectId], + ) + return rows.length > 0 + } + + async getDirectDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + const rows = await Mysql.execute( + `SELECT d.* FROM nt_delegate d + JOIN ${delegateTable(resource)} o ON ${delegateIdColumn(resource)} = d.nt_object_id + WHERE d.nt_group_id = ? AND d.nt_object_id = ? AND d.nt_object_type = ? + AND d.deleted = 0 AND o.deleted = 0`, + [groupId, objectId, type], + ) + return rows.length > 0 ? rows[0] : null + } + + async getDelegatedZoneIds(groupIds) { + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]) + .map(Number) + .filter(Number.isInteger) + if (gids.length === 0) return [] + const placeholders = gids.map(() => '?').join(', ') + const rows = await Mysql.execute( + `SELECT d.nt_object_id AS id + FROM nt_delegate d + JOIN nt_zone z ON z.nt_zone_id = d.nt_object_id + WHERE d.nt_group_id IN (${placeholders}) + AND d.nt_object_type = 'ZONE' AND d.deleted = 0 AND z.deleted = 0 + UNION + SELECT r.nt_zone_id AS id + FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id IN (${placeholders}) + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [...gids, ...gids], + ) + return rows.map((row) => row.id) + } + + async delegatedRecordIdsInZone(groupId, zoneId) { + const rows = await Mysql.execute( + `SELECT r.nt_zone_record_id AS id + FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? AND r.nt_zone_id = ? + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [groupId, zoneId], + ) + return rows.map((row) => row.id) + } + + async zoneDelegationForRecord(groupId, zoneRecordId) { + const rows = await Mysql.execute( + `SELECT d.*, 1 AS pseudo FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? + AND r.nt_zone_record_id = ? + AND d.nt_object_type = 'ZONE' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [groupId, zoneRecordId], + ) + return rows.length > 0 ? rows[0] : null + } + + async liveSessionGroup(userId, sessionId, oldestSec) { + const rows = await Mysql.execute( + `SELECT u.nt_group_id AS gid + FROM nt_user u + JOIN nt_group g ON g.nt_group_id = u.nt_group_id + JOIN nt_user_session s ON s.nt_user_id = u.nt_user_id + WHERE u.nt_user_id = ? AND s.nt_user_session_id = ? + AND u.deleted = 0 AND g.deleted = 0 + AND s.last_access >= ?`, + [userId, sessionId, oldestSec], + ) + return rows.length === 0 ? null : rows[0].gid + } + + async permissionRecord(permissionId) { + const rows = await Mysql.execute( + `SELECT NULLIF(p.nt_user_id, 0) AS uid, + NULLIF(p.nt_group_id, 0) AS gid, + COALESCE(NULLIF(p.nt_group_id, 0), u.nt_group_id) AS target_gid + FROM nt_perm p + LEFT JOIN nt_user u ON u.nt_user_id = p.nt_user_id + WHERE p.nt_perm_id = ?`, + [permissionId], + ) + return rows.length === 0 ? null : rows[0] + } +} + +export default AuthzRepoMysql diff --git a/lib/config.js b/lib/config.js index 25655ca..74886a9 100644 --- a/lib/config.js +++ b/lib/config.js @@ -166,6 +166,9 @@ function applyEnvOverrides(name, cfg) { if (name === 'http') { if (process.env.NICTOOL_HTTP_HOST) cfg.host = process.env.NICTOOL_HTTP_HOST if (process.env.NICTOOL_HTTP_PORT) cfg.port = parsePort('NICTOOL_HTTP_PORT') + if (process.env.NICTOOL_HTTP_LIST_LIMIT_MAX) { + cfg.list_limit_max = parseInt(process.env.NICTOOL_HTTP_LIST_LIMIT_MAX, 10) + } } if (name === 'store') { if (process.env.NICTOOL_DATA_STORE) cfg.type = process.env.NICTOOL_DATA_STORE diff --git a/lib/delegation/index.js b/lib/delegation/index.js new file mode 100644 index 0000000..ca9ad29 --- /dev/null +++ b/lib/delegation/index.js @@ -0,0 +1,18 @@ +import { storeType } from '../config.js' + +const type = storeType() + +let RepoClass +switch (type) { + case 'json': + case 'toml': + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default + break + default: + throw new Error(`delegation: no store implementation for type "${type}"`) +} + +export default new RepoClass() diff --git a/lib/delegation/store/base.js b/lib/delegation/store/base.js new file mode 100644 index 0000000..15ccf6c --- /dev/null +++ b/lib/delegation/store/base.js @@ -0,0 +1,58 @@ +/** + * Delegation domain class – pure contract and cross-cutting logic. + * + * Has zero knowledge of how delegations are persisted. All delegation + * repository classes must extend this class and implement the repo contract. + * + * Repo contract: + * create(args) → { created: true } | { duplicate: true } + * getDelegated(gid, type) → object[] (delegations a group holds) + * getDelegates(oid, type, gid?) → object[] (groups holding a delegation) + * put(args) → true | null (null when nothing to update) + * delete(args) → true | null + * writeLog(data, action) → void + */ +export const PERM_FIELDS = [ + 'perm_write', + 'perm_delete', + 'perm_delegate', + 'zone_perm_add_records', + 'zone_perm_delete_records', +] + +class DelegationBase { + async create(_args) { + throw new Error('create() not implemented by this store') + } + + async getDelegated(_gid, _type) { + throw new Error('getDelegated() not implemented by this store') + } + + async getDelegates(_oid, _type, _gid) { + throw new Error('getDelegates() not implemented by this store') + } + + async put(_args) { + throw new Error('put() not implemented by this store') + } + + async delete(_args) { + throw new Error('delete() not implemented by this store') + } + + async writeLog(_data, _action) { + throw new Error('writeLog() not implemented by this store') + } + + /** Route a public get({gid, oid, type}) call to the right repo method. */ + async get(args) { + const { gid, oid } = args + const type = args.type ?? 'ZONE' + if (oid !== undefined) return this.getDelegates(oid, type, gid) + if (gid !== undefined) return this.getDelegated(gid, type) + return [] + } +} + +export default DelegationBase diff --git a/lib/delegation/store/file.js b/lib/delegation/store/file.js new file mode 100644 index 0000000..f110f12 --- /dev/null +++ b/lib/delegation/store/file.js @@ -0,0 +1,171 @@ +import FileStore from '../../store/file.js' + +import DelegationBase, { PERM_FIELDS } from './base.js' + +const TYPES = { + ZONE: { file: 'zone', idCol: 'nt_zone_id' }, + ZONERECORD: { file: 'zone_record', idCol: 'nt_zone_record_id' }, + NAMESERVER: { file: 'nameserver', idCol: 'nt_nameserver_id' }, + GROUP: { file: 'group', idCol: 'nt_group_id' }, +} + +class DelegationRepoFile extends DelegationBase { + constructor() { + super() + this.file = new FileStore('delegation') + this.logFile = new FileStore('delegate_log') + } + + async _load() { + return this.file.load('delegation') + } + + async _save(rows) { + return this.file.save('delegation', rows) + } + + // delegations join against four entity files; each is a separate document, + // so lookups that mysql does with a JOIN happen row-at-a-time here + async _object(type, oid) { + const meta = TYPES[type] + if (!meta) return null + const rows = await new FileStore(meta.file).load(meta.file) + return rows.find((r) => r.id === oid && r.deleted !== true) ?? null + } + + async _activeGroupNames() { + const groups = await new FileStore('group').load('group') + return new Map(groups.filter((g) => g.deleted !== true).map((g) => [g.id, g.name])) + } + + async _present(rows) { + const names = await this._activeGroupNames() + return rows.map((row) => ({ + nt_group_id: row.gid, + nt_object_id: row.oid, + nt_object_type: row.type, + group_name: names.get(row.gid) ?? '', + delegated_by_id: row.delegated_by_id ?? 0, + delegated_by_name: row.delegated_by_name ?? '', + delegate_write: row.perm_write ? 1 : 0, + delegate_delete: row.perm_delete ? 1 : 0, + delegate_delegate: row.perm_delegate ? 1 : 0, + delegate_add_records: row.zone_perm_add_records ? 1 : 0, + delegate_delete_records: row.zone_perm_delete_records ? 1 : 0, + })) + } + + async create(args) { + const { gid, oid, type } = args + if (!TYPES[type]) return {} + + const rows = await this._load() + if (rows.some((r) => r.gid === gid && r.oid === oid && r.type === type)) { + return { duplicate: true } + } + + const row = { + gid, + oid, + type, + delegated_by_id: args.delegated_by_id ?? 0, + delegated_by_name: args.delegated_by_name ?? '', + } + for (const f of PERM_FIELDS) row[f] = args[f] === true + + rows.push(row) + await this._save(rows) + + await this.writeLog({ ...row, ...permsToInt(row) }, 'delegated') + + return { created: true } + } + + async getDelegated(gid, type) { + if (!TYPES[type]) return [] + const rows = (await this._load()).filter( + (r) => r.gid === gid && r.type === type, + ) + const active = [] + for (const row of rows) { + if (await this._object(type, row.oid)) active.push(row) + } + const presented = await this._present(active) + const meta = TYPES[type] + for (const p of presented) p[meta.idCol] = p.nt_object_id + return presented + } + + async getDelegates(oid, type, gid) { + if (!TYPES[type]) return [] + let rows = (await this._load()).filter( + (r) => r.oid === oid && r.type === type, + ) + if (gid !== undefined) rows = rows.filter((r) => r.gid === gid) + return this._present(rows) + } + + async put(args) { + const { gid, oid, type } = args + const rows = await this._load() + const row = rows.find((r) => r.gid === gid && r.oid === oid && r.type === type) + if (!row) return null + + const updates = {} + for (const f of PERM_FIELDS) { + if (args[f] !== undefined) updates[f] = args[f] === true + } + if (Object.keys(updates).length === 0) return true + + Object.assign(row, updates) + await this._save(rows) + + await this.writeLog( + { ...row, delegated_by_id: args.delegated_by_id, delegated_by_name: args.delegated_by_name }, + 'modified', + ) + + return true + } + + async delete(args) { + const { gid, oid, type } = args + let rows = await this._load() + const row = rows.find((r) => r.gid === gid && r.oid === oid && r.type === type) + if (!row) return null + + rows = rows.filter((r) => r !== row) + await this._save(rows) + + await this.writeLog(row, 'deleted') + + return true + } + + async writeLog(data, action) { + const logs = await this.logFile.load('delegate_log') + logs.push({ + nt_user_id: data.delegated_by_id ?? 0, + nt_user_name: data.delegated_by_name ?? '', + action, + nt_object_type: data.nt_object_type ?? data.type, + nt_object_id: data.nt_object_id ?? data.oid, + nt_group_id: data.nt_group_id ?? data.gid, + timestamp: Math.floor(Date.now() / 1000), + perm_write: (data.perm_write ?? true) ? 1 : 0, + perm_delete: (data.perm_delete ?? true) ? 1 : 0, + perm_delegate: (data.perm_delegate ?? true) ? 1 : 0, + zone_perm_add_records: (data.zone_perm_add_records ?? true) ? 1 : 0, + zone_perm_delete_records: (data.zone_perm_delete_records ?? true) ? 1 : 0, + }) + await this.logFile.save('delegate_log', logs) + } +} + +function permsToInt(row) { + const out = {} + for (const f of PERM_FIELDS) out[f] = row[f] ? 1 : 0 + return out +} + +export default DelegationRepoFile diff --git a/lib/delegation.js b/lib/delegation/store/mysql.js similarity index 86% rename from lib/delegation.js rename to lib/delegation/store/mysql.js index abe4023..5815d86 100644 --- a/lib/delegation.js +++ b/lib/delegation/store/mysql.js @@ -1,4 +1,6 @@ -import Mysql from './mysql.js' +import Mysql from '../../mysql.js' + +import DelegationBase, { PERM_FIELDS } from './base.js' const TYPE_META = { ZONE: { table: 'nt_zone', idCol: 'nt_zone_id' }, @@ -7,19 +9,7 @@ const TYPE_META = { GROUP: { table: 'nt_group', idCol: 'nt_group_id' }, } -const PERM_FIELDS = [ - 'perm_write', - 'perm_delete', - 'perm_delegate', - 'zone_perm_add_records', - 'zone_perm_delete_records', -] - -class Delegation { - constructor() { - this.mysql = Mysql - } - +class DelegationRepoMysql extends DelegationBase { async create(args) { const { gid, oid, type } = args @@ -44,27 +34,15 @@ class Delegation { await Mysql.execute(...Mysql.insert('nt_delegate', row)) - await this.log(row, 'delegated') + await this.writeLog(row, 'delegated') return { created: true } } - async get(args) { - const { gid, oid, type } = args - const objType = type ?? 'ZONE' + async getDelegated(gid, objType) { const meta = TYPE_META[objType] if (!meta) return [] - if (oid !== undefined) { - return this.getDelegates(oid, objType, gid) - } - if (gid !== undefined) { - return this.getDelegated(gid, objType, meta) - } - return [] - } - - async getDelegated(gid, objType, meta) { const query = `SELECT d.nt_group_id, d.nt_object_id, @@ -118,7 +96,7 @@ class Delegation { const { gid, oid, type } = args const existing = await Mysql.execute( - `SELECT nt_group_id, perm_write, perm_delete, perm_delegate, + `SELECT perm_write, perm_delete, perm_delegate, zone_perm_add_records, zone_perm_delete_records FROM nt_delegate WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, @@ -132,7 +110,6 @@ class Delegation { updates[f] = args[f] === true ? 1 : 0 } } - if (Object.keys(updates).length === 0) return true const setClauses = Object.keys(updates) @@ -146,7 +123,7 @@ class Delegation { values, ) - await this.log( + await this.writeLog( { nt_group_id: gid, nt_object_id: oid, @@ -166,7 +143,7 @@ class Delegation { const { gid, oid, type } = args const existing = await Mysql.execute( - `SELECT nt_group_id, perm_write, perm_delete, perm_delegate, + `SELECT perm_write, perm_delete, perm_delegate, zone_perm_add_records, zone_perm_delete_records FROM nt_delegate WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, @@ -174,7 +151,7 @@ class Delegation { ) if (existing.length === 0) return null - await this.log( + await this.writeLog( { nt_group_id: gid, nt_object_id: oid, @@ -195,7 +172,7 @@ class Delegation { return true } - async log(data, action) { + async writeLog(data, action) { const row = { nt_user_id: data.delegated_by_id ?? 0, nt_user_name: data.delegated_by_name ?? '', @@ -215,4 +192,4 @@ class Delegation { } } -export default new Delegation() +export default DelegationRepoMysql diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index e18adf7..d299d0c 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -42,20 +42,26 @@ class Group extends GroupBase { return gid } - async addToSubgroups(gid, parent_gid, rank = 1000) { + async addToSubgroups(gid, parent_gid, rank = 1000, db = Mysql) { if (!parent_gid || parent_gid === 0) return // `rank` is a reserved word in MySQL 8.0+, so it must be backticked; the // generic Mysql.insert helper doesn't quote column names. - await Mysql.execute('INSERT INTO nt_group_subgroups (nt_group_id,nt_subgroup_id,`rank`) VALUES(?,?,?)', [ + await db.execute('INSERT INTO nt_group_subgroups (nt_group_id,nt_subgroup_id,`rank`) VALUES(?,?,?)', [ parent_gid, gid, rank, ]) - const parent = await this.get({ id: parent_gid }) + const parent = await db.execute( + 'SELECT parent_group_id AS parent_gid FROM nt_group WHERE nt_group_id = ?', + [parent_gid], + ) if (parent.length === 1 && parent[0].parent_gid !== 0) { - await this.addToSubgroups(gid, parent[0].parent_gid, rank - 1) + // both the insert and the parent walk must use the same connection: + // reads through the shared connection cannot see this transaction's + // uncommitted writes and would resurrect stale ancestor rows + await this.addToSubgroups(gid, parent[0].parent_gid, rank - 1, db) } } @@ -157,29 +163,25 @@ class Group extends GroupBase { if (Object.keys(args).length === 0) return true - const update = () => Mysql.execute( - ...Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)), + const update = () => Mysql.update( + `nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap), ) if (args.parent_gid === undefined) { - const r = await update() + const r = await Mysql.execute(...update()) return r.changedRows === 1 } - await Mysql.execute('START TRANSACTION') - try { - const r = await update() - await this.rebuildSubgroups(id) - await Mysql.execute('COMMIT') + return Mysql.transaction(async (tx) => { + const r = await tx.execute(...update()) + await this.rebuildSubgroups(id, tx) return r.changedRows === 1 - } catch (err) { - await Mysql.execute('ROLLBACK') - throw err - } + }) } - async rebuildSubgroups(rootGid) { - const groups = await Mysql.execute( + async rebuildSubgroups(rootGid, conn = Mysql) { + const db = conn.execute ? conn : Mysql + const groups = await db.execute( `WITH RECURSIVE descendants AS ( SELECT nt_group_id AS id, parent_group_id AS parent_gid FROM nt_group WHERE nt_group_id = ? @@ -194,12 +196,12 @@ class Group extends GroupBase { if (groups.length === 0) return const placeholders = groups.map(() => '?').join(', ') - await Mysql.execute( + await db.execute( `DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (${placeholders})`, groups.map((group) => group.id), ) for (const group of groups) { - await this.addToSubgroups(group.id, group.parent_gid) + await this.addToSubgroups(group.id, group.parent_gid, 1000, db) } } diff --git a/lib/mysql.js b/lib/mysql.js index ee219ff..18000bf 100644 --- a/lib/mysql.js +++ b/lib/mysql.js @@ -48,6 +48,34 @@ class Mysql { return rows } + // The shared connection cannot hold a transaction: concurrent requests + // would interleave statements inside it. Transactions run on their own + // connection and are handed an object with execute/insert/update helpers. + async transaction(fn) { + const cfg = await Config.get('mysql') + const conn = await mysql.createConnection(cfg) + try { + await conn.beginTransaction() + const tx = { + execute: async (query, paramsArray) => { + const [rows] = await conn.execute(query, paramsArray ?? []) + if (/^(REPLACE|INSERT) INTO/.test(query)) return rows.insertId + return rows + }, + insert: (table, params) => this.insert(table, params), + update: (table, where, params) => this.update(table, where, params), + } + const result = await fn(tx) + await conn.commit() + return result + } catch (err) { + await conn.rollback() + throw err + } finally { + await conn.destroy() + } + } + insert(table, params = {}) { return [ `INSERT INTO ${table} (${Object.keys(params).join(',')}) VALUES(${Object.keys(params).map(() => '?')})`, diff --git a/lib/page.js b/lib/page.js new file mode 100644 index 0000000..302e9bf --- /dev/null +++ b/lib/page.js @@ -0,0 +1,13 @@ +import Config from './config.js' + +// The default matches the historical route defaults. The ceiling is +// operator-tunable because the right value depends on production data volume. +const DEFAULT_MAX = 1000 + +export async function pageLimit(requested, fallback = DEFAULT_MAX) { + const cfg = await Config.get('http') + const max = Number.isInteger(cfg.list_limit_max) && cfg.list_limit_max > 0 + ? cfg.list_limit_max + : DEFAULT_MAX + return Math.min(Math.max(1, Number.isInteger(requested) ? requested : fallback), max) +} diff --git a/lib/page.test.js b/lib/page.test.js new file mode 100644 index 0000000..d116040 --- /dev/null +++ b/lib/page.test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { describe, it, after } from 'node:test' + +import Config from './config.js' +import { pageLimit } from './page.js' + +describe('pageLimit', () => { + const savedEnv = process.env.NICTOOL_HTTP_LIST_LIMIT_MAX + + after(() => { + if (savedEnv === undefined) delete process.env.NICTOOL_HTTP_LIST_LIMIT_MAX + else process.env.NICTOOL_HTTP_LIST_LIMIT_MAX = savedEnv + Config.cfg = {} + }) + + it('applies the default and clamps both ends', async () => { + assert.equal(await pageLimit(undefined), 1000) + assert.equal(await pageLimit(undefined, 50), 50) + assert.equal(await pageLimit(50), 50) + assert.equal(await pageLimit(5000), 1000) + assert.equal(await pageLimit(0), 1) + assert.equal(await pageLimit(-10), 1) + }) + + it('honors the operator ceiling', async () => { + process.env.NICTOOL_HTTP_LIST_LIMIT_MAX = '25' + Config.cfg = {} + assert.equal(await pageLimit(50), 25) + assert.equal(await pageLimit(10), 10) + }) +}) diff --git a/lib/store-access.test.js b/lib/store-access.test.js new file mode 100644 index 0000000..eeb77fb --- /dev/null +++ b/lib/store-access.test.js @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// Data access goes through lib//store/ per AGENTS.md. Only stores +// may talk to the mysql wrapper; this walks the source tree and +// fails on any other module that reaches for it. +const ROOTS = ['lib', 'routes'] +const SKIP_PATH = (p) => + p.split(path.sep).includes('store') || p.endsWith('.test.js') + +function sourceFiles(dir) { + // this test lives in lib/, so the package root is one level up + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', dir) + return readdirSync(root, { recursive: true }) + .filter((f) => f.endsWith('.js')) + .filter((f) => !SKIP_PATH(f)) + .map((f) => path.join(root, f)) +} + +function importSpecifiers(source) { + return [...source.matchAll(/(?:from|import)\s*\(?['"]([^'"]+)['"]/g)] + .map((m) => m[1]) +} + +describe('store access', () => { + it('keeps mysql access inside store modules', () => { + const offenders = [] + for (const dir of ROOTS) { + for (const file of sourceFiles(dir)) { + if (file.endsWith(`${path.sep}mysql.js`)) continue + const hits = importSpecifiers(readFileSync(file, 'utf8')) + .filter((spec) => (spec === 'mysql2' || spec.endsWith('/mysql.js')) + && !spec.includes('store/')) // dispatchers pick a backend from store/ + if (hits.length > 0) offenders.push(`${file}: ${hits.join(', ')}`) + } + } + assert.deepEqual(offenders, []) + }) +}) diff --git a/lib/zone/store/base.js b/lib/zone/store/base.js index f65a539..6c551b5 100644 --- a/lib/zone/store/base.js +++ b/lib/zone/store/base.js @@ -8,6 +8,7 @@ * put(args) → boolean * delete(args) → boolean * destroy(args) → boolean + * nameserversFor(zid) → object[] ({zone, name, ttl} for the zone's NS) */ class ZoneBase { constructor(args = {}) { @@ -18,6 +19,10 @@ class ZoneBase { throw new Error('get() not implemented by this repo') } + async nameserversFor(_zid) { + throw new Error('nameserversFor() not implemented by this repo') + } + async count(_args) { throw new Error('count() not implemented by this repo') } diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index 2db3eed..cb71799 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -164,6 +164,24 @@ class ZoneRepoFile extends ZoneBase { await this._save(filtered) return true } + + // the zone<->nameserver mapping is its own document; a deployment that + // hasn't created it simply has no NS records to serve + async nameserversFor(zid) { + const mappings = await new FileStore('zone_nameserver').load('zone_nameserver') + const zones = await this._load() + const nameservers = await new FileStore('nameserver').load('nameserver') + + return mappings + .filter((m) => m.zid === zid) + .map((m) => ({ + zone: zones.find((z) => z.id === zid)?.zone, + name: nameservers.find((n) => n.id === m.nid)?.name, + ttl: nameservers.find((n) => n.id === m.nid)?.ttl, + })) + .filter((row) => row.name !== undefined) + .sort((a, b) => (a.name < b.name ? -1 : 1)) + } } export default ZoneRepoFile diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index 831f9ae..66d09fa 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -215,6 +215,18 @@ class ZoneRepoMySQL extends ZoneBase { disconnect() { return this.mysql?.disconnect() } + + async nameserversFor(zid) { + return Mysql.execute( + `SELECT z.zone, n.name, n.ttl + FROM nt_zone_nameserver nzns + JOIN nt_nameserver n ON n.nt_nameserver_id = nzns.nt_nameserver_id + JOIN nt_zone z ON z.nt_zone_id = nzns.nt_zone_id + WHERE nzns.nt_zone_id = ? + ORDER BY n.name`, + [zid], + ) + } } export default ZoneRepoMySQL diff --git a/routes/authz.test.js b/routes/authz.test.js index 74ad860..c4fa850 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -8,7 +8,7 @@ import Zone from '../lib/zone/index.js' import ZoneRecord from '../lib/zone_record/index.js' import Nameserver from '../lib/nameserver/index.js' import Permission from '../lib/permission/index.js' -import Delegation from '../lib/delegation.js' +import Delegation from '../lib/delegation/index.js' import Mysql from '../lib/mysql.js' const G_ROOT = { diff --git a/routes/delegation.js b/routes/delegation.js index 2a6c181..49b2183 100644 --- a/routes/delegation.js +++ b/routes/delegation.js @@ -1,7 +1,7 @@ import validate from '@nictool/validate' -import Authz from '../lib/authz.js' -import Delegation from '../lib/delegation.js' +import Authz from '../lib/authz/index.js' +import Delegation from '../lib/delegation/index.js' import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' diff --git a/routes/group.js b/routes/group.js index 4450d18..70b1d9a 100644 --- a/routes/group.js +++ b/routes/group.js @@ -3,7 +3,7 @@ import validate from '@nictool/validate' import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' -import Authz from '../lib/authz.js' +import Authz from '../lib/authz/index.js' import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' diff --git a/routes/log.js b/routes/log.js index a9e05c4..55ddfe1 100644 --- a/routes/log.js +++ b/routes/log.js @@ -1,6 +1,6 @@ import validate from '@nictool/validate' -import Audit from '../lib/audit.js' +import Audit from '../lib/audit/index.js' import Group from '../lib/group/index.js' import { meta } from '../lib/util.js' diff --git a/routes/permission.js b/routes/permission.js index 04e28f9..0db40e5 100644 --- a/routes/permission.js +++ b/routes/permission.js @@ -1,6 +1,6 @@ import validate from '@nictool/validate' -import Authz from '../lib/authz.js' +import Authz from '../lib/authz/index.js' import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' diff --git a/routes/user.js b/routes/user.js index 81a7417..37a5305 100644 --- a/routes/user.js +++ b/routes/user.js @@ -2,8 +2,9 @@ import validate from '@nictool/validate' import User from '../lib/user/index.js' import Credentials from '../lib/user/credentials.js' -import Authz from '../lib/authz.js' +import Authz from '../lib/authz/index.js' import Permission from '../lib/permission/index.js' +import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' const PERM_FIELDS = new Set([ @@ -77,7 +78,7 @@ function UserRoutes(server) { gid: parseInt(gid, 10), deleted: request.query.deleted ?? false, include_subgroups: request.query.include_subgroups === true, - limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, + limit: await pageLimit(request.query.limit), } if (request.query.search) getArgs.search = request.query.search diff --git a/routes/zone.js b/routes/zone.js index ef7190e..11e7e3a 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -2,9 +2,9 @@ import validate from '@nictool/validate' import Zone from '../lib/zone/index.js' import Group from '../lib/group/index.js' -import Authz from '../lib/authz.js' -import Mysql from '../lib/mysql.js' -import Audit from '../lib/audit.js' +import Authz from '../lib/authz/index.js' +import Audit from '../lib/audit/index.js' +import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' const ZONE_PUT_FIELDS = new Set([ @@ -36,7 +36,7 @@ function ZoneRoutes(server) { }, handler: async (request, h) => { const getArgs = { - limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, + limit: await pageLimit(request.query.limit), } if (request.query.deleted !== undefined) { getArgs.deleted = request.query.deleted === true @@ -207,15 +207,7 @@ function ZoneRoutes(server) { handler: async (request, h) => { const zid = parseInt(request.params.id, 10) - const nsRows = await Mysql.execute( - `SELECT z.zone, n.name, n.ttl - FROM nt_zone_nameserver nzns - JOIN nt_nameserver n ON n.nt_nameserver_id = nzns.nt_nameserver_id - JOIN nt_zone z ON z.nt_zone_id = nzns.nt_zone_id - WHERE nzns.nt_zone_id = ? - ORDER BY n.name`, - [zid], - ) + const nsRows = await Zone.nameserversFor(zid) const ns = nsRows.map((row) => { const zoneFqdn = row.zone.endsWith('.') ? row.zone : `${row.zone}.` diff --git a/routes/zone_record.js b/routes/zone_record.js index 087dd1b..2bbb78a 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -2,8 +2,9 @@ import validate from '@nictool/validate' import ZoneRecord from '../lib/zone_record/index.js' import Zone from '../lib/zone/index.js' -import Authz from '../lib/authz.js' -import Audit from '../lib/audit.js' +import Authz from '../lib/authz/index.js' +import Audit from '../lib/audit/index.js' +import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' async function zoneRecordResponseFailAction(request, h, err) { @@ -56,7 +57,7 @@ function ZoneRecordRoutes(server) { const deleted = request.query.deleted === true ? 1 : 0 const getArgs = { deleted, - limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, + limit: await pageLimit(request.query.limit), } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) if (request.query.zid) getArgs.zid = parseInt(request.query.zid, 10) From d3142f4e8fa8101b598e1e0172500591d6e17e01 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:31:10 +0100 Subject: [PATCH 08/48] test: json file store coverage for new stores --- lib/file-stores.test.js | 151 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 lib/file-stores.test.js diff --git a/lib/file-stores.test.js b/lib/file-stores.test.js new file mode 100644 index 0000000..85c0975 --- /dev/null +++ b/lib/file-stores.test.js @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { after, before, describe, it } from 'node:test' + +import Config from './config.js' + +// Exercises the json file backends directly. The mysql behavior of these +// subsystems is covered by the mysql-backend suite; this covers the parity +// story that keeps a file-store deployment working. +const saved = { + storeType: process.env.NICTOOL_DATA_STORE, + storePath: process.env.NICTOOL_DATA_STORE_PATH, +} + +function freshStores() { + return Promise.all([ + import('./delegation/store/file.js'), + import('./audit/store/file.js'), + import('./authz/store/file.js'), + ]) +} + +before(() => { + process.env.NICTOOL_DATA_STORE = 'json' + process.env.NICTOOL_DATA_STORE_PATH = mkdtempSync(`${tmpdir()}/nt-file-stores-`) + delete Config.cfg.store +}) + +after(() => { + rmSync(process.env.NICTOOL_DATA_STORE_PATH, { recursive: true, force: true }) + if (saved.storeType === undefined) delete process.env.NICTOOL_DATA_STORE + else process.env.NICTOOL_DATA_STORE = saved.storeType + if (saved.storePath === undefined) delete process.env.NICTOOL_DATA_STORE_PATH + else process.env.NICTOOL_DATA_STORE_PATH = saved.storePath +}) + +describe('file-store delegation', () => { + it('creates, lists, updates, and deletes a delegation', async () => { + const FileDelegation = new (await freshStores().then(([d]) => d.default)) + const created = await FileDelegation.create({ + gid: 42, oid: 7, type: 'ZONE', perm_write: true, delegated_by_id: 2, + }) + assert.ok(created.created) + + assert.ok((await FileDelegation.create({ gid: 42, oid: 7, type: 'ZONE' })).duplicate) + + let rows = await FileDelegation.getDelegated(42, 'ZONE') + assert.equal(rows.length, 0) // zone 7 does not exist in the empty entity files + + const delegates = await FileDelegation.getDelegates(7, 'ZONE') + assert.equal(delegates[0].delegate_write, 1) + + assert.ok(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE', perm_write: false })) + assert.equal((await FileDelegation.getDelegates(7, 'ZONE'))[0].delegate_write, 0) + + assert.ok(await FileDelegation.delete({ gid: 42, oid: 7, type: 'ZONE' })) + assert.equal(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE' }), null) + assert.deepEqual(await FileDelegation.getDelegates(7, 'ZONE'), []) + }) +}) + +describe('file-store audit', () => { + it('records and lists entries with search, sort, and pagination', async () => { + const FileAudit = new (await freshStores().then(([, a]) => a.default)) + // the global listing resolves actors through the user file + await seedEntity('user', [{ + id: 5, gid: 3, username: 'auditor', first_name: 'Audie', last_name: 'Tor', + }]) + const actor = { id: 5 } + const zone = { + id: 9, gid: 3, zone: 'audit.test.', mailaddr: 'hm.audit.test.', + serial: 1, ttl: 3600, + } + + await FileAudit.logZone(actor, 'added', zone) + await FileAudit.logZone(actor, 'modified', { ...zone, serial: 2 }, { gid: 1 }) + + const list = await FileAudit.listZones({ gids: [3] }) + assert.equal(list.total, 2) + assert.equal(list.rows[0].action, 'modified') // default sort: newest first + + assert.equal(list.rows[0].user, 'Audie Tor (auditor)') + + const paged = await FileAudit.listZones({ gids: [3], limit: 1, offset: 1 }) + assert.equal(paged.rows.length, 1) + assert.equal(paged.rows[0].action, 'added') + + const searched = await FileAudit.listZones({ gids: [3], search: 'MODIF' }) + assert.equal(searched.filtered, 1) + + const global = await FileAudit.listGlobal({ gids: [3] }) + assert.equal(global.total, 2) + assert.equal(global.rows[0].description, 'modified zone') + }) + + it('rejects an unscoped listing', async () => { + const FileAudit = new (await freshStores().then(([, a]) => a.default)) + assert.equal((await FileAudit.listZones({ gids: [] })).total, 0) + }) +}) + +describe('file-store authz', () => { + it('resolves object groups and group trees', async () => { + const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) + await seedEntity('group', [ + { id: 1, name: 'root' }, + { id: 10, name: 'parent', parent_gid: 1 }, + { id: 11, name: 'child', parent_gid: 10 }, + ]) + await seedEntity('zone', [{ id: 20, gid: 11, zone: 'z.test.' }]) + await seedEntity('user', [{ id: 30, gid: 10, username: 'u', first_name: 'U', last_name: 'One' }]) + + assert.equal(await FileAuthz.getObjectGroupId('zone', 20), 11) + assert.equal(await FileAuthz.isInGroupTree(10, 11), true) + assert.equal(await FileAuthz.isInGroupTree(11, 10), false) + assert.equal(await FileAuthz.isActiveGroup(10), true) + assert.equal(await FileAuthz.isActiveObject('user', 30), true) + assert.equal(await FileAuthz.getObjectGroupId('zone', 999), null) + }) + + it('honors delegations for record access', async () => { + const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) + const FileDelegation = new (await freshStores().then(([d]) => d.default)) + + await seedEntity('group', [{ id: 50, name: 'holder' }, { id: 51, name: 'owner' }]) + await seedEntity('zone', [{ id: 60, gid: 51, zone: 'del.test.' }]) + await seedEntity('zone_record', [{ id: 61, zid: 60, owner: 'a.del.test.' }]) + await FileDelegation.create({ + gid: 50, oid: 61, type: 'ZONERECORD', + perm_write: true, zone_perm_add_records: true, + }) + // a delegation on the zone itself surfaces when acting on its records + await FileDelegation.create({ + gid: 50, oid: 60, type: 'ZONE', perm_write: true, + }) + + assert.deepEqual(await FileAuthz.delegatedRecordIdsInZone(50, 60), [61]) + assert.deepEqual(await FileAuthz.getDelegatedZoneIds([50]), [60]) + const pseudo = await FileAuthz.zonePseudoDelegation(50, 60) + assert.equal(pseudo.pseudo, 1) + const viaRecord = await FileAuthz.zoneDelegationForRecord(50, 61) + assert.equal(viaRecord.pseudo, 1) + assert.equal(viaRecord.perm_write, 1) + }) +}) + +async function seedEntity(name, rows) { + const { default: FileStore } = await import('./store/file.js') + await new FileStore(name).save(name, rows) +} From 40d7beede93f10af1ca54d0b73a6f22df589a25d Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:19 +0100 Subject: [PATCH 09/48] stores: align file backend behavior --- lib/audit/index.js | 6 ++ lib/audit/store/elasticsearch.js | 29 +++++++ lib/audit/store/mongodb.js | 29 +++++++ lib/authz/index.js | 6 ++ lib/authz/store/elasticsearch.js | 45 +++++++++++ lib/authz/store/file.js | 15 ++-- lib/authz/store/mongodb.js | 45 +++++++++++ lib/delegation/index.js | 6 ++ lib/delegation/store/elasticsearch.js | 29 +++++++ lib/delegation/store/file.js | 7 +- lib/delegation/store/mongodb.js | 29 +++++++ lib/file-stores.test.js | 65 +++++++++++++++- lib/group/store/file.js | 37 +++------ lib/permission/index.js | 6 ++ lib/permission/store/elasticsearch.js | 29 +++++++ lib/permission/store/file.js | 54 ++++++++++--- lib/permission/store/mongodb.js | 29 +++++++ lib/permission/test/index.js | 16 +++- lib/session/store/file.js | 32 +++++--- lib/store-stubs.test.js | 70 +++++++++++++++++ lib/user/store/file.js | 108 ++++++++++++++++---------- routes/group.test.js | 5 +- 22 files changed, 594 insertions(+), 103 deletions(-) create mode 100644 lib/audit/store/elasticsearch.js create mode 100644 lib/audit/store/mongodb.js create mode 100644 lib/authz/store/elasticsearch.js create mode 100644 lib/authz/store/mongodb.js create mode 100644 lib/delegation/store/elasticsearch.js create mode 100644 lib/delegation/store/mongodb.js create mode 100644 lib/permission/store/elasticsearch.js create mode 100644 lib/permission/store/mongodb.js create mode 100644 lib/store-stubs.test.js diff --git a/lib/audit/index.js b/lib/audit/index.js index fb168fb..cc01d00 100644 --- a/lib/audit/index.js +++ b/lib/audit/index.js @@ -11,6 +11,12 @@ switch (type) { case 'mysql': RepoClass = (await import('./store/mysql.js')).default break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break default: throw new Error(`audit: no store implementation for type "${type}"`) } diff --git a/lib/audit/store/elasticsearch.js b/lib/audit/store/elasticsearch.js new file mode 100644 index 0000000..7d1beea --- /dev/null +++ b/lib/audit/store/elasticsearch.js @@ -0,0 +1,29 @@ +import AuditBase from './base.js' + +class AuditRepoElasticsearch extends AuditBase { + async insertZoneLog(_detail) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async insertZoneRecordLog(_detail) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async insertGlobalLog(_entry) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async listGlobal(_args) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async listZones(_args) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async listZoneRecords(_args) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } +} + +export default AuditRepoElasticsearch diff --git a/lib/audit/store/mongodb.js b/lib/audit/store/mongodb.js new file mode 100644 index 0000000..b09a410 --- /dev/null +++ b/lib/audit/store/mongodb.js @@ -0,0 +1,29 @@ +import AuditBase from './base.js' + +class AuditRepoMongoDB extends AuditBase { + async insertZoneLog(_detail) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async insertZoneRecordLog(_detail) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async insertGlobalLog(_entry) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async listGlobal(_args) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async listZones(_args) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async listZoneRecords(_args) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } +} + +export default AuditRepoMongoDB diff --git a/lib/authz/index.js b/lib/authz/index.js index 891a470..2b79318 100644 --- a/lib/authz/index.js +++ b/lib/authz/index.js @@ -11,6 +11,12 @@ switch (type) { case 'mysql': RepoClass = (await import('./store/mysql.js')).default break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break default: throw new Error(`authz: no store implementation for type "${type}"`) } diff --git a/lib/authz/store/elasticsearch.js b/lib/authz/store/elasticsearch.js new file mode 100644 index 0000000..13c0fdf --- /dev/null +++ b/lib/authz/store/elasticsearch.js @@ -0,0 +1,45 @@ +import AuthzBase from './base.js' + +class AuthzRepoElasticsearch extends AuthzBase { + async getObjectGroupId(_resource, _objectId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async isInGroupTree(_userGroupId, _targetGroupId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async isActiveGroup(_groupId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async isActiveObject(_resource, _objectId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async getDirectDelegateAccess(_groupId, _objectId, _resource) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async getDelegatedZoneIds(_groupIds) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async delegatedRecordIdsInZone(_groupId, _zoneId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async zoneDelegationForRecord(_groupId, _zoneRecordId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async liveSessionGroup(_userId, _sessionId, _oldestSec) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async permissionRecord(_permissionId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } +} + +export default AuthzRepoElasticsearch diff --git a/lib/authz/store/file.js b/lib/authz/store/file.js index 545f63d..20d1b7c 100644 --- a/lib/authz/store/file.js +++ b/lib/authz/store/file.js @@ -28,11 +28,11 @@ class AuthzRepoFile extends AuthzBase { async getObjectGroupId(resource, objectId) { if (resource === 'zonerecord') { const record = await this._object('zonerecord', objectId) - if (!record || record.deleted === true) return null + if (!record) return null return this.getObjectGroupId('zone', record.zid) } const row = await this._object(resource, objectId) - if (!row || row.deleted === true) return null + if (!row) return null if (resource === 'group') { // the root group has no parent; v2 routes its objects to group 1 return row.parent_gid ?? row.gid ?? 1 @@ -148,10 +148,8 @@ class AuthzRepoFile extends AuthzBase { const group = groups.find((g) => g.id === user.gid && g.deleted !== true) if (!group) return null - // file stores keep no last_access per session; presence is the best - // available signal that the session is still live const session = sessions.find( - (s) => s.user_id === userId && s.id === sessionId && (s.last_access ?? Infinity) >= oldestSec, + (s) => s.uid === userId && s.id === sessionId && (s.last_access ?? Infinity) >= oldestSec, ) return session ? user.gid : null } @@ -162,10 +160,9 @@ class AuthzRepoFile extends AuthzBase { const groups = await this._rows('group') for (const u of users) { - for (const p of u.permissions ?? []) { - if (p.permissionId === permissionId || p.id === permissionId) { - return { uid: u.id, gid: null, target_gid: u.gid } - } + const p = u.permissions + if (p && (p.permissionId === permissionId || p.id === permissionId)) { + return { uid: u.id, gid: p.group?.id ?? u.gid, target_gid: u.gid } } } for (const g of groups) { diff --git a/lib/authz/store/mongodb.js b/lib/authz/store/mongodb.js new file mode 100644 index 0000000..40a5065 --- /dev/null +++ b/lib/authz/store/mongodb.js @@ -0,0 +1,45 @@ +import AuthzBase from './base.js' + +class AuthzRepoMongoDB extends AuthzBase { + async getObjectGroupId(_resource, _objectId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async isInGroupTree(_userGroupId, _targetGroupId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async isActiveGroup(_groupId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async isActiveObject(_resource, _objectId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async getDirectDelegateAccess(_groupId, _objectId, _resource) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async getDelegatedZoneIds(_groupIds) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async delegatedRecordIdsInZone(_groupId, _zoneId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async zoneDelegationForRecord(_groupId, _zoneRecordId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async liveSessionGroup(_userId, _sessionId, _oldestSec) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async permissionRecord(_permissionId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } +} + +export default AuthzRepoMongoDB diff --git a/lib/delegation/index.js b/lib/delegation/index.js index ca9ad29..8f0c68c 100644 --- a/lib/delegation/index.js +++ b/lib/delegation/index.js @@ -11,6 +11,12 @@ switch (type) { case 'mysql': RepoClass = (await import('./store/mysql.js')).default break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break default: throw new Error(`delegation: no store implementation for type "${type}"`) } diff --git a/lib/delegation/store/elasticsearch.js b/lib/delegation/store/elasticsearch.js new file mode 100644 index 0000000..d3c724a --- /dev/null +++ b/lib/delegation/store/elasticsearch.js @@ -0,0 +1,29 @@ +import DelegationBase from './base.js' + +class DelegationRepoElasticsearch extends DelegationBase { + async create(_args) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async getDelegated(_gid, _type) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async getDelegates(_oid, _type, _gid) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async put(_args) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async delete(_args) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async writeLog(_data, _action) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } +} + +export default DelegationRepoElasticsearch diff --git a/lib/delegation/store/file.js b/lib/delegation/store/file.js index f110f12..bab3c49 100644 --- a/lib/delegation/store/file.js +++ b/lib/delegation/store/file.js @@ -40,7 +40,7 @@ class DelegationRepoFile extends DelegationBase { async _present(rows) { const names = await this._activeGroupNames() - return rows.map((row) => ({ + return rows.filter((row) => names.has(row.gid)).map((row) => ({ nt_group_id: row.gid, nt_object_id: row.oid, nt_object_type: row.type, @@ -137,7 +137,10 @@ class DelegationRepoFile extends DelegationBase { rows = rows.filter((r) => r !== row) await this._save(rows) - await this.writeLog(row, 'deleted') + await this.writeLog( + { ...row, delegated_by_id: args.delegated_by_id, delegated_by_name: args.delegated_by_name }, + 'deleted', + ) return true } diff --git a/lib/delegation/store/mongodb.js b/lib/delegation/store/mongodb.js new file mode 100644 index 0000000..1de734e --- /dev/null +++ b/lib/delegation/store/mongodb.js @@ -0,0 +1,29 @@ +import DelegationBase from './base.js' + +class DelegationRepoMongoDB extends DelegationBase { + async create(_args) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async getDelegated(_gid, _type) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async getDelegates(_oid, _type, _gid) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async put(_args) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async delete(_args) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async writeLog(_data, _action) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } +} + +export default DelegationRepoMongoDB diff --git a/lib/file-stores.test.js b/lib/file-stores.test.js index 85c0975..bd1b8e5 100644 --- a/lib/file-stores.test.js +++ b/lib/file-stores.test.js @@ -38,6 +38,7 @@ after(() => { describe('file-store delegation', () => { it('creates, lists, updates, and deletes a delegation', async () => { const FileDelegation = new (await freshStores().then(([d]) => d.default)) + await seedEntity('group', [{ id: 42, name: 'delegate holder' }]) const created = await FileDelegation.create({ gid: 42, oid: 7, type: 'ZONE', perm_write: true, delegated_by_id: 2, }) @@ -54,9 +55,22 @@ describe('file-store delegation', () => { assert.ok(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE', perm_write: false })) assert.equal((await FileDelegation.getDelegates(7, 'ZONE'))[0].delegate_write, 0) - assert.ok(await FileDelegation.delete({ gid: 42, oid: 7, type: 'ZONE' })) + assert.ok(await FileDelegation.delete({ + gid: 42, + oid: 7, + type: 'ZONE', + delegated_by_id: 9, + delegated_by_name: 'deleting user', + })) assert.equal(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE' }), null) assert.deepEqual(await FileDelegation.getDelegates(7, 'ZONE'), []) + + const { default: FileStore } = await import('./store/file.js') + const logs = await new FileStore('delegate_log').load('delegate_log') + assert.deepEqual( + { uid: logs.at(-1).nt_user_id, name: logs.at(-1).nt_user_name }, + { uid: 9, name: 'deleting user' }, + ) }) }) @@ -119,6 +133,25 @@ describe('file-store authz', () => { assert.equal(await FileAuthz.getObjectGroupId('zone', 999), null) }) + it('uses the persisted session and permission shapes', async () => { + const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) + await seedEntity('group', [ + { id: 10, name: 'parent', permissions: { id: 10 } }, + { id: 11, name: 'deleted-owner', parent_gid: 10 }, + ]) + await seedEntity('user', [ + { id: 30, gid: 10, username: 'u', permissions: { id: 30 } }, + ]) + await seedEntity('session', [{ id: 40, uid: 30, last_access: 2_000_000_000 }]) + await seedEntity('zone', [{ id: 50, gid: 11, zone: 'deleted.test.', deleted: true }]) + + assert.equal(await FileAuthz.liveSessionGroup(30, 40, 1_000_000_000), 10) + assert.deepEqual(await FileAuthz.permissionRecord(30), { + uid: 30, gid: 10, target_gid: 10, + }) + assert.equal(await FileAuthz.getObjectGroupId('zone', 50), 11) + }) + it('honors delegations for record access', async () => { const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) const FileDelegation = new (await freshStores().then(([d]) => d.default)) @@ -143,6 +176,36 @@ describe('file-store authz', () => { assert.equal(viaRecord.pseudo, 1) assert.equal(viaRecord.perm_write, 1) }) + + it('applies delegated collection scopes', async () => { + const { default: FileZone } = await import('./zone/store/file.js') + const { default: FileZoneRecord } = await import('./zone_record/store/file.js') + const zones = new FileZone() + const records = new FileZoneRecord() + + await seedEntity('zone', [ + { id: 60, gid: 50, zone: 'owned.test.' }, + { id: 61, gid: 51, zone: 'delegated.test.' }, + { id: 62, gid: 51, zone: 'hidden.test.' }, + ]) + await seedEntity('zone_record', [ + { id: 70, zid: 61, owner: 'allowed.delegated.test.' }, + { id: 71, zid: 61, owner: 'hidden.delegated.test.' }, + ]) + + assert.deepEqual( + (await zones.get({ gid: 50, accessible_ids: [61] })).map((z) => z.id), + [61, 60], + ) + assert.equal(await zones.count({ gid: 50, accessible_ids: [61] }), 2) + assert.deepEqual( + (await records.get({ zid: 61, ids: [70] })).map((r) => r.id), + [70], + ) + assert.equal(await records.count({ zid: 61, ids: [70] }), 1) + assert.deepEqual(await records.get({ zid: 61, ids: [] }), []) + assert.equal(await records.count({ zid: 61, ids: [] }), 0) + }) }) async function seedEntity(name, rows) { diff --git a/lib/group/store/file.js b/lib/group/store/file.js index a5044c4..268a4bd 100644 --- a/lib/group/store/file.js +++ b/lib/group/store/file.js @@ -1,17 +1,8 @@ import FileStore from '../../store/file.js' +import Permission from '../../permission/index.js' import GroupBase from './base.js' -const defaultPermissions = { - inherit: false, - self_write: false, - group: { create: false, write: false, delete: false }, - nameserver: { usable: [], create: false, write: false, delete: false }, - zone: { create: false, write: false, delete: false, delegate: false }, - zonerecord: { create: false, write: false, delete: false, delegate: false }, - user: { create: false, write: false, delete: false }, -} - class GroupRepoFile extends GroupBase { constructor(args = {}) { super(args) @@ -68,24 +59,16 @@ class GroupRepoFile extends GroupBase { const usable_ns = args.usable_ns ?? [] delete args.usable_ns - const gid = args.id - args.permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: gid, - name: `Group ${args.name} perms`, - user: { id: gid, create: false, write: false, delete: false }, - group: { id: gid, create: false, write: false, delete: false }, - nameserver: { - usable: Array.isArray(usable_ns) ? usable_ns : [], - create: false, - write: false, - delete: false, - }, - } - const groups = await this._load() groups.push(args) await this._save(groups) + + const gid = args.id + await Permission.create({ + gid, + name: `Group ${args.name} perms`, + nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] }, + }) return gid } @@ -136,9 +119,7 @@ class GroupRepoFile extends GroupBase { } } - if (Object.keys(args).length > 0) { - groups[idx] = { ...groups[idx], ...args } - } + if (Object.keys(args).length > 0) groups[idx] = { ...groups[idx], ...args } await this._save(groups) return true diff --git a/lib/permission/index.js b/lib/permission/index.js index f301ceb..7ed7787 100644 --- a/lib/permission/index.js +++ b/lib/permission/index.js @@ -11,6 +11,12 @@ switch (type) { case 'mysql': RepoClass = (await import('./store/mysql.js')).default break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break default: throw new Error(`permission: no store implementation for type "${type}"`) } diff --git a/lib/permission/store/elasticsearch.js b/lib/permission/store/elasticsearch.js new file mode 100644 index 0000000..c709597 --- /dev/null +++ b/lib/permission/store/elasticsearch.js @@ -0,0 +1,29 @@ +import PermissionBase from './base.js' + +class PermissionRepoElasticsearch extends PermissionBase { + async create(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async get(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async getGroup(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async put(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async delete(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async destroy(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } +} + +export default PermissionRepoElasticsearch diff --git a/lib/permission/store/file.js b/lib/permission/store/file.js index 15e7cdd..b2ef63a 100644 --- a/lib/permission/store/file.js +++ b/lib/permission/store/file.js @@ -53,6 +53,18 @@ class PermissionRepoFile extends PermissionBase { return this.standaloneFile.save('permission', permissions) } + async _nextId() { + const [users, groups, standalone] = await Promise.all([ + this._loadUsers(), + this._loadGroups(), + this._loadStandalone(), + ]) + return [...users, ...groups] + .map((row) => row.permissions?.id) + .concat(standalone.map((row) => row.id)) + .reduce((max, id) => Math.max(max, id ?? 0), 0) + 1 + } + // --------------------------------------------------------------------------- // Post-processing // --------------------------------------------------------------------------- @@ -64,6 +76,8 @@ class PermissionRepoFile extends PermissionBase { delete r.uid delete r.gid r.deleted = Boolean(r.deleted) + if (r.user && r.user.id === undefined) r.user.id = null + if (r.group && r.group.id === undefined) r.group.id = null if (r.nameserver && !Array.isArray(r.nameserver.usable)) r.nameserver.usable = [] if (deletedArg === false) delete r.deleted return r @@ -74,7 +88,7 @@ class PermissionRepoFile extends PermissionBase { // --------------------------------------------------------------------------- async create(args) { - args = JSON.parse(JSON.stringify(args)) + args = expandFlatPermissions(JSON.parse(JSON.stringify(args))) const uid = args.uid ?? args.user?.id const gid = args.gid ?? args.group?.id delete args.uid @@ -87,11 +101,9 @@ class PermissionRepoFile extends PermissionBase { if (idx !== -1) { // Store inline in user.toml using the actual permission data from args if (!users[idx].permissions) { - const perm = JSON.parse(JSON.stringify(args)) - perm.id = uid - if (!perm.user) perm.user = {} + const perm = deepMerge(permissionDefaults(uid, gid ?? users[idx].gid), args) + perm.id = args.id ?? await this._nextId() perm.user.id = uid - if (!perm.group) perm.group = {} perm.group.id = gid ?? users[idx].gid users[idx].permissions = perm } @@ -109,9 +121,8 @@ class PermissionRepoFile extends PermissionBase { if (idx !== -1) { // Store inline in group.toml if (!groups[idx].permissions) { - const perm = JSON.parse(JSON.stringify(args)) - perm.id = gid - if (!perm.group) perm.group = {} + const perm = deepMerge(permissionDefaults(null, gid), args) + perm.id = args.id ?? await this._nextId() perm.group.id = gid groups[idx].permissions = perm } @@ -202,7 +213,7 @@ class PermissionRepoFile extends PermissionBase { } async put(args) { - args = JSON.parse(JSON.stringify(args)) + args = expandFlatPermissions(JSON.parse(JSON.stringify(args))) if (!args.id) return false const id = args.id delete args.id @@ -322,4 +333,29 @@ function deepMerge(target, source) { return result } +function expandFlatPermissions(permission) { + for (const resource of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { + for (const action of ['create', 'write', 'delete', 'delegate']) { + const field = `${resource}_${action}` + if (permission[field] === undefined) continue + permission[resource] ??= {} + permission[resource][action] = Boolean(permission[field]) + delete permission[field] + } + } + return permission +} + +function permissionDefaults(uid, gid) { + return { + inherit: false, + self_write: false, + group: { id: gid, create: false, write: false, delete: false }, + nameserver: { usable: [], create: false, write: false, delete: false }, + zone: { create: false, write: false, delete: false, delegate: false }, + zonerecord: { create: false, write: false, delete: false, delegate: false }, + user: { id: uid, create: false, write: false, delete: false }, + } +} + export default PermissionRepoFile diff --git a/lib/permission/store/mongodb.js b/lib/permission/store/mongodb.js new file mode 100644 index 0000000..24cd975 --- /dev/null +++ b/lib/permission/store/mongodb.js @@ -0,0 +1,29 @@ +import PermissionBase from './base.js' + +class PermissionRepoMongoDB extends PermissionBase { + async create(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async get(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async getGroup(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async put(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async delete(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async destroy(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } +} + +export default PermissionRepoMongoDB diff --git a/lib/permission/test/index.js b/lib/permission/test/index.js index f4773bd..27f9e67 100644 --- a/lib/permission/test/index.js +++ b/lib/permission/test/index.js @@ -10,6 +10,8 @@ import userTestCase from '../../user/test/user.json' with { type: 'json' } import permTestCase from './permission.json' with { type: 'json' } before(async () => { + await Group.create({ id: 1, parent_gid: 0, name: 'root' }) + await Permission.create({ gid: 1, name: 'Root group permissions' }) await Group.create(groupTestCase) await User.create(userTestCase) }) @@ -55,10 +57,20 @@ describe('permission', function () { }) it('changes a permission', async () => { - assert.ok(await Permission.put({ id: permTestCase.id, name: 'Changed' })) + assert.ok(await Permission.put({ + id: permTestCase.id, + name: 'Changed', + group_write: 1, + })) const perm = await Permission.get({ id: permTestCase.id }) assert.deepEqual(perm.name, 'Changed') - assert.ok(await Permission.put({ id: permTestCase.id, name: 'Test Permission' })) + assert.equal(perm.group.write, true) + assert.equal(perm.group_write, undefined) + assert.ok(await Permission.put({ + id: permTestCase.id, + name: 'Test Permission', + group_write: 0, + })) }) it('reactivates a soft-deleted permission instead of duplicating it', async () => { diff --git a/lib/session/store/file.js b/lib/session/store/file.js index d3d959f..f713fc2 100644 --- a/lib/session/store/file.js +++ b/lib/session/store/file.js @@ -1,5 +1,19 @@ import FileStore from '../../store/file.js' +let sessionWriteQueue = Promise.resolve() + +async function withSessionWriteLock(fn) { + const previous = sessionWriteQueue + let release + sessionWriteQueue = new Promise((resolve) => { release = resolve }) + await previous + try { + return await fn() + } finally { + release() + } +} + // Map legacy nt_* column names to the friendly API names used throughout. function normalizeArgs(args) { if (args.nt_user_session_id !== undefined) { @@ -32,16 +46,14 @@ class SessionRepoFile { async create(args) { args = normalizeArgs(JSON.parse(JSON.stringify(args))) - - const existing = await this.get({ uid: args.uid, session: args.session }) - if (existing) return existing.id - - const sessions = await this._load() - const nextId = sessions.reduce((max, s) => Math.max(max, s.id ?? 0), 0) + 1 - args.id = nextId - sessions.push(args) - await this._save(sessions) - return nextId + return withSessionWriteLock(async () => { + const sessions = await this._load() + const nextId = sessions.reduce((max, s) => Math.max(max, s.id ?? 0), 0) + 1 + args.id = nextId + sessions.push(args) + await this._save(sessions) + return nextId + }) } async get(args) { diff --git a/lib/store-stubs.test.js b/lib/store-stubs.test.js new file mode 100644 index 0000000..22dba5d --- /dev/null +++ b/lib/store-stubs.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { describe, it } from 'node:test' + +const contracts = [ + { + name: 'audit', + methods: [ + 'insertZoneLog', + 'insertZoneRecordLog', + 'insertGlobalLog', + 'listGlobal', + 'listZones', + 'listZoneRecords', + ], + }, + { + name: 'authz', + methods: [ + 'getObjectGroupId', + 'isInGroupTree', + 'isActiveGroup', + 'isActiveObject', + 'getDirectDelegateAccess', + 'getDelegatedZoneIds', + 'delegatedRecordIdsInZone', + 'zoneDelegationForRecord', + 'liveSessionGroup', + 'permissionRecord', + ], + }, + { + name: 'delegation', + methods: ['create', 'getDelegated', 'getDelegates', 'put', 'delete', 'writeLog'], + }, + { + name: 'permission', + methods: ['create', 'get', 'getGroup', 'put', 'delete', 'destroy'], + }, +] + +describe('new subsystem store stubs', () => { + for (const backend of ['mongodb', 'elasticsearch']) { + it(`dispatches all new subsystems to ${backend}`, () => { + const source = `await Promise.all([ + import('./lib/audit/index.js'), + import('./lib/authz/index.js'), + import('./lib/delegation/index.js'), + import('./lib/permission/index.js'), + ])` + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], { + cwd: process.cwd(), + env: { ...process.env, NICTOOL_DATA_STORE: backend }, + encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr) + }) + + for (const contract of contracts) { + it(`${contract.name} has a loud ${backend} stub`, async () => { + const { default: Repo } = await import(`./${contract.name}/store/${backend}.js`) + const repo = new Repo() + for (const method of contract.methods) { + assert.ok(Object.hasOwn(Repo.prototype, method), `${method} must be explicit`) + await assert.rejects(() => repo[method]({}), /not yet implemented/) + } + }) + } + } +}) diff --git a/lib/user/store/file.js b/lib/user/store/file.js index 4fb135c..1918d17 100644 --- a/lib/user/store/file.js +++ b/lib/user/store/file.js @@ -1,9 +1,23 @@ import FileStore from '../../store/file.js' import Config from '../../config.js' import Credentials from '../credentials.js' +import Permission from '../../permission/index.js' import UserBase from './base.js' const boolFields = ['is_admin', 'deleted'] +let userWriteQueue = Promise.resolve() + +async function withUserWriteLock(fn) { + const previous = userWriteQueue + let release + userWriteQueue = new Promise((resolve) => { release = resolve }) + await previous + try { + return await fn() + } finally { + release() + } +} // Read the group file directly (never via the Group module) to avoid circular imports. async function loadGroupPerm(groupFile, gid) { @@ -11,14 +25,16 @@ async function loadGroupPerm(groupFile, gid) { return groups.find((g) => g.id === gid)?.permissions ?? null } -const defaultPermissions = { - inherit: false, - self_write: false, - group: { create: false, write: false, delete: false }, - nameserver: { usable: [], create: false, write: false, delete: false }, - zone: { create: false, write: false, delete: false, delegate: false }, - zonerecord: { create: false, write: false, delete: false, delegate: false }, - user: { create: false, write: false, delete: false }, +async function groupIds(groupFile, gid, includeSubgroups) { + if (!includeSubgroups) return [gid] + const groups = await groupFile.load('group') + const ids = [gid] + for (let i = 0; i < ids.length; i += 1) { + for (const group of groups) { + if (group.parent_gid === ids[i] && !ids.includes(group.id)) ids.push(group.id) + } + } + return ids } class UserRepoFile extends UserBase { @@ -94,7 +110,10 @@ class UserRepoFile extends UserBase { let users = await this._load() if (args.id !== undefined) users = users.filter((u) => u.id === args.id) - if (args.gid !== undefined) users = users.filter((u) => u.gid === args.gid) + if (args.gid !== undefined) { + const gids = await groupIds(this.groupFile, args.gid, args.include_subgroups === true) + users = users.filter((u) => gids.includes(u.gid)) + } if (args.username !== undefined) users = users.filter((u) => u.username === args.username) if (deletedArg === false) users = users.filter((u) => !u.deleted) else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) @@ -140,7 +159,10 @@ class UserRepoFile extends UserBase { let users = await this._load() if (args.id !== undefined) users = users.filter((u) => u.id === args.id) - if (args.gid !== undefined) users = users.filter((u) => u.gid === args.gid) + if (args.gid !== undefined) { + const gids = await groupIds(this.groupFile, args.gid, args.include_subgroups === true) + users = users.filter((u) => gids.includes(u.gid)) + } if (args.username !== undefined) users = users.filter((u) => u.username === args.username) if (deletedArg === false) users = users.filter((u) => !u.deleted) else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) @@ -157,33 +179,34 @@ class UserRepoFile extends UserBase { } async create(args) { - if (args.id) { - const existing = await this.get({ id: args.id }) - if (existing.length === 1) return existing[0].id - } - - args = JSON.parse(JSON.stringify(args)) + return withUserWriteLock(async () => { + const users = await this._load() + if (args.id && users.some((user) => user.id === args.id)) return args.id - const inherit = args.inherit_group_permissions - delete args.inherit_group_permissions + args = JSON.parse(JSON.stringify(args)) + if (args.id === undefined) { + args.id = users.reduce((max, user) => Math.max(max, user.id ?? 0), 0) + 1 + } - if (args.password) { - Object.assign(args, await Credentials.forStorage(args.password, args.pass_salt)) - } + const inherit = args.inherit_group_permissions + delete args.inherit_group_permissions - if (inherit === false) { - args.permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: args.id, - user: { id: args.id, create: false, write: false, delete: false }, - group: { id: args.gid, create: false, write: false, delete: false }, + if (args.password) { + Object.assign(args, await Credentials.forStorage(args.password, args.pass_salt)) } - } - const users = await this._load() - users.push(args) - await this._save(users) - return args.id + users.push(args) + await this._save(users) + if (inherit === false) { + await Permission.create({ + uid: args.id, + gid: args.gid, + inherit: false, + name: `User ${args.username} perms`, + }) + } + return args.id + }) } async put(args) { @@ -198,22 +221,25 @@ class UserRepoFile extends UserBase { delete args.inherit_group_permissions if (inherit === true) { - // Switch to inherited: remove explicit permissions delete users[idx].permissions - } else if (inherit === false && !users[idx].permissions) { - // Switch to explicit: create default permission entry - users[idx].permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: users[idx].id, - user: { id: users[idx].id, create: false, write: false, delete: false }, - group: { id: users[idx].gid, create: false, write: false, delete: false }, - } } else if (inherit === false && users[idx].permissions) { users[idx].permissions.inherit = false } + if (args.gid !== undefined && users[idx].permissions?.group) { + users[idx].permissions.group.id = args.gid + } + users[idx] = { ...users[idx], ...args } await this._save(users) + if (inherit === false && !users[idx].permissions) { + await Permission.create({ + uid: users[idx].id, + gid: users[idx].gid, + inherit: false, + name: `User ${users[idx].username} perms`, + }) + } return true } diff --git a/routes/group.test.js b/routes/group.test.js index 33580d8..347a6ac 100644 --- a/routes/group.test.js +++ b/routes/group.test.js @@ -3,6 +3,7 @@ import { describe, it, before, after } from 'node:test' import { init } from './index.js' import Group from '../lib/group/index.js' +import Permission from '../lib/permission/index.js' import User from '../lib/user/index.js' import groupCase from './test/group.json' with { type: 'json' } @@ -17,6 +18,8 @@ before(async () => { await Group.create(groupCase) await User.create(userCase) await grantGroupPermissions(groupCase.id) + const permissions = await Permission.getEffective(userCase.id) + assert.equal(permissions.group.create, true) }) after(async () => { @@ -66,7 +69,7 @@ describe('group routes', () => { headers: auth.headers, payload: testCase, }) - assert.equal(res.statusCode, 201) + assert.equal(res.statusCode, 201, JSON.stringify(res.result)) }) it(`GET /group/${case2Id}`, async () => { From 839479918ce4a00eff3f85652db0d0a061f54aaf Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:25 +0100 Subject: [PATCH 10/48] user: keep moves and totals in sync --- lib/authz/store/mysql.js | 2 +- lib/user/store/mysql.js | 35 +++++++++++++++++++++++++++++------ routes/user.js | 7 ++++++- routes/user.test.js | 18 ++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/lib/authz/store/mysql.js b/lib/authz/store/mysql.js index 409fa2c..1c40a9b 100644 --- a/lib/authz/store/mysql.js +++ b/lib/authz/store/mysql.js @@ -166,7 +166,7 @@ class AuthzRepoMysql extends AuthzBase { const rows = await Mysql.execute( `SELECT NULLIF(p.nt_user_id, 0) AS uid, NULLIF(p.nt_group_id, 0) AS gid, - COALESCE(NULLIF(p.nt_group_id, 0), u.nt_group_id) AS target_gid + COALESCE(u.nt_group_id, NULLIF(p.nt_group_id, 0)) AS target_gid FROM nt_perm p LEFT JOIN nt_user u ON u.nt_user_id = p.nt_user_id WHERE p.nt_perm_id = ?`, diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index bb04e5b..50fc494 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -210,6 +210,9 @@ class UserRepoMySQL extends UserBase { args = JSON.parse(JSON.stringify(args)) if (args.deleted === undefined) args.deleted = false + const includeSubgroups = args.include_subgroups === true + delete args.include_subgroups + const params = [] const where = [] @@ -221,8 +224,18 @@ class UserRepoMySQL extends UserBase { params.push(args.id) } if (args.gid !== undefined) { - where.push('nt_group_id = ?') - params.push(args.gid) + if (includeSubgroups) { + const subgroupRows = await Mysql.execute( + 'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?', + [args.gid], + ) + const gids = [args.gid, ...subgroupRows.map((r) => r.nt_subgroup_id)] + where.push(`nt_group_id IN (${gids.map(() => '?').join(',')})`) + params.push(...gids) + } else { + where.push('nt_group_id = ?') + params.push(args.gid) + } } if (args.username !== undefined) { where.push('username = ?') @@ -275,10 +288,20 @@ class UserRepoMySQL extends UserBase { if (Object.keys(args).length === 0) return true - const r = await Mysql.execute( - ...Mysql.update(`nt_user`, `nt_user_id=${id}`, mapToDbColumn(args, userDbMap)), - ) - return r.changedRows === 1 + const userArgs = mapToDbColumn(args, userDbMap) + if (args.gid === undefined) { + const r = await Mysql.execute(...Mysql.update(`nt_user`, `nt_user_id=${id}`, userArgs)) + return r.changedRows === 1 + } + + return Mysql.transaction(async (tx) => { + const r = await tx.execute(...tx.update(`nt_user`, `nt_user_id=${id}`, userArgs)) + await tx.execute( + 'UPDATE nt_perm SET nt_group_id = ? WHERE nt_user_id = ? AND deleted = 0', + [args.gid, id], + ) + return r.changedRows === 1 + }) } async delete(args) { diff --git a/routes/user.js b/routes/user.js index 37a5305..5ad2147 100644 --- a/routes/user.js +++ b/routes/user.js @@ -90,10 +90,15 @@ function UserRoutes(server) { const countArgs = { gid: getArgs.gid, deleted: getArgs.deleted, + include_subgroups: getArgs.include_subgroups, ...(getArgs.search ? { search: getArgs.search } : {}), ...(getArgs.exact_match ? { exact_match: true } : {}), } - const totalArgs = { gid: getArgs.gid, deleted: getArgs.deleted } + const totalArgs = { + gid: getArgs.gid, + deleted: getArgs.deleted, + include_subgroups: getArgs.include_subgroups, + } const [users, filtered, total] = await Promise.all([ User.get(getArgs), User.count(countArgs), diff --git a/routes/user.test.js b/routes/user.test.js index 1ebabc1..31c0f3c 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -4,6 +4,8 @@ import { describe, it, before, after } from 'node:test' import { init } from './index.js' import User from '../lib/user/index.js' import Group from '../lib/group/index.js' +import Permission from '../lib/permission/index.js' +import Authz from '../lib/authz/index.js' import groupCase from './test/group.json' with { type: 'json' } import { grantGroupPermissions } from './test/permissions.js' @@ -66,6 +68,7 @@ describe('user routes', () => { const testCase = JSON.parse(JSON.stringify(userCase)) testCase.id = userId2 // make it unique testCase.username = `${testCase.username}2` + testCase.inherit_group_permissions = false delete testCase.deleted const res = await server.inject({ @@ -99,6 +102,18 @@ describe('user routes', () => { }) assert.equal(moved.statusCode, 200) assert.equal((await User.get({ id: userId2 }))[0].gid, moveGroup.id) + const movedPermission = await Permission.get({ uid: userId2 }) + assert.equal(movedPermission.group.id, moveGroup.id) + assert.equal((await Authz.permissionRecord(movedPermission.id)).target_gid, moveGroup.id) + + const listed = await server.inject({ + method: 'GET', + url: `/user?gid=${groupCase.id}&include_subgroups=true`, + headers: auth.headers, + }) + assert.equal(listed.statusCode, 200) + assert.ok(listed.result.user.some((u) => u.id === userId2)) + assert.equal(listed.result.meta.pagination.total, listed.result.user.length) const restored = await server.inject({ method: 'PUT', @@ -108,6 +123,9 @@ describe('user routes', () => { }) assert.equal(restored.statusCode, 200) assert.equal((await User.get({ id: userId2 }))[0].gid, groupCase.id) + const restoredPermission = await Permission.get({ uid: userId2 }) + assert.equal(restoredPermission.group.id, groupCase.id) + assert.equal((await Authz.permissionRecord(restoredPermission.id)).target_gid, groupCase.id) }) it(`GET /user/${userId2}`, async () => { From b4a69653904cf5e257a4a802b323f4a9ff47137e Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:33 +0100 Subject: [PATCH 11/48] zone: enforce canonical names atomically --- lib/zone/store/base.js | 11 +++++++ lib/zone/store/file.js | 72 +++++++++++++++++++++++++++++------------ lib/zone/store/mysql.js | 49 ++++++++++++++++++++++++++-- lib/zone/test/mysql.js | 8 +++-- routes/zone.js | 33 +++++++++++-------- routes/zone.test.js | 57 ++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 38 deletions(-) diff --git a/lib/zone/store/base.js b/lib/zone/store/base.js index 6c551b5..749cbf0 100644 --- a/lib/zone/store/base.js +++ b/lib/zone/store/base.js @@ -49,3 +49,14 @@ class ZoneBase { } export default ZoneBase + +export function canonicalZoneName(zone) { + return zone.replace(/\.+$/, '').toLowerCase() +} + +export class ZoneNameConflictError extends Error { + constructor(zone) { + super(`Zone is already taken: ${zone}`) + this.code = 'ZONE_NAME_CONFLICT' + } +} diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index cb71799..84bf17e 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -1,8 +1,30 @@ import FileStore, { resolveCodec } from '../../store/file.js' -import ZoneBase from './base.js' +import ZoneBase, { canonicalZoneName, ZoneNameConflictError } from './base.js' const zoneDefaults = { minimum: 3600, ttl: 3600, refresh: 86400, retry: 7200, expire: 1209600 } +let zoneWriteQueue = Promise.resolve() + +async function withZoneWriteLock(fn) { + const previous = zoneWriteQueue + let release + zoneWriteQueue = new Promise((resolve) => { release = resolve }) + await previous + try { + return await fn() + } finally { + release() + } +} + +function assertZoneNameAvailable(zones, zone, excludeId) { + const canonical = canonicalZoneName(zone) + if (zones.some((row) => ( + row.id !== excludeId && row.deleted !== true && canonicalZoneName(row.zone) === canonical + ))) { + throw new ZoneNameConflictError(zone) + } +} class ZoneRepoFile extends ZoneBase { constructor(args = {}) { @@ -35,15 +57,14 @@ class ZoneRepoFile extends ZoneBase { } async create(args) { - if (args.id) { - const existing = await this.get({ id: args.id }) - if (existing.length === 1) return existing[0].id - } - - const zones = await this._load() - zones.push(JSON.parse(JSON.stringify(args))) - await this._save(zones) - return args.id + return withZoneWriteLock(async () => { + const zones = await this._load() + if (args.id && zones.some((zone) => zone.id === args.id)) return args.id + if (args.deleted !== true) assertZoneNameAvailable(zones, args.zone) + zones.push(JSON.parse(JSON.stringify(args))) + await this._save(zones) + return args.id + }) } async get(args) { @@ -53,14 +74,17 @@ class ZoneRepoFile extends ZoneBase { const { search, zone_like, description_like, sort_by, sort_dir, limit, offset } = args const id = args.id const gid = args.gid + const accessibleIds = Array.isArray(args.accessible_ids) ? args.accessible_ids : [] const zone = args.zone let zones = await this._load() // Direct field filters if (id !== undefined) zones = zones.filter((z) => z.id === id) - if (Array.isArray(gid)) zones = zones.filter((z) => gid.includes(z.gid)) - else if (gid !== undefined) zones = zones.filter((z) => z.gid === gid) + if (gid !== undefined) { + const gids = Array.isArray(gid) ? gid : [gid] + zones = zones.filter((z) => gids.includes(z.gid) || accessibleIds.includes(z.id)) + } if (zone !== undefined) zones = zones.filter((z) => z.zone === zone) if (deletedArg === false) zones = zones.filter((z) => !z.deleted) else if (deletedArg !== undefined) zones = zones.filter((z) => Boolean(z.deleted) === Boolean(deletedArg)) @@ -108,12 +132,15 @@ class ZoneRepoFile extends ZoneBase { const { search, zone_like, description_like } = args const id = args.id const gid = args.gid + const accessibleIds = Array.isArray(args.accessible_ids) ? args.accessible_ids : [] let zones = await this._load() if (id !== undefined) zones = zones.filter((z) => z.id === id) - if (Array.isArray(gid)) zones = zones.filter((z) => gid.includes(z.gid)) - else if (gid !== undefined) zones = zones.filter((z) => z.gid === gid) + if (gid !== undefined) { + const gids = Array.isArray(gid) ? gid : [gid] + zones = zones.filter((z) => gids.includes(z.gid) || accessibleIds.includes(z.id)) + } if (deletedArg === false) zones = zones.filter((z) => !z.deleted) else if (deletedArg !== undefined) zones = zones.filter((z) => Boolean(z.deleted) === Boolean(deletedArg)) @@ -137,13 +164,16 @@ class ZoneRepoFile extends ZoneBase { async put(args) { if (!args.id) return false - const zones = await this._load() - const idx = zones.findIndex((z) => z.id === args.id) - if (idx === -1) return false - - zones[idx] = { ...zones[idx], ...args } - await this._save(zones) - return true + return withZoneWriteLock(async () => { + const zones = await this._load() + const idx = zones.findIndex((z) => z.id === args.id) + if (idx === -1) return false + if (args.deleted === false) assertZoneNameAvailable(zones, zones[idx].zone, args.id) + + zones[idx] = { ...zones[idx], ...args } + await this._save(zones) + return true + }) } async delete(args) { diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index 66d09fa..e0a87f8 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -1,10 +1,36 @@ +import { createHash } from 'node:crypto' + import Mysql from '../../mysql.js' -import ZoneBase from './base.js' +import ZoneBase, { canonicalZoneName, ZoneNameConflictError } from './base.js' import { mapToDbColumn } from '../../util.js' const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' } const boolFields = ['deleted'] +function zoneLockName(zone) { + return `nictool-zone-${createHash('sha256').update(canonicalZoneName(zone)).digest('hex')}` +} + +async function assertZoneNameAvailable(db, zone, excludeId) { + let query = `SELECT nt_zone_id AS id FROM nt_zone + WHERE deleted = 0 AND LOWER(TRIM(TRAILING '.' FROM zone)) = ?` + const params = [canonicalZoneName(zone)] + if (excludeId !== undefined) { + query += ' AND nt_zone_id <> ?' + params.push(excludeId) + } + query += ' LIMIT 1' + if ((await db.execute(query, params)).length > 0) throw new ZoneNameConflictError(zone) +} + +async function withZoneNameLock(zone, fn) { + return Mysql.transaction(async (tx) => { + const [lock] = await tx.execute('SELECT GET_LOCK(?, 10) AS acquired', [zoneLockName(zone)]) + if (lock.acquired !== 1) throw new Error(`Could not lock zone name: ${zone}`) + return fn(tx) + }) +} + function applyAccessScope(query, params, gidScope, accessibleIds) { if (gidScope === undefined) return [query, params] const gidList = Array.isArray(gidScope) ? gidScope : [gidScope] @@ -61,7 +87,10 @@ class ZoneRepoMySQL extends ZoneBase { if (g.length === 1) return g[0].id } - return await Mysql.execute(...Mysql.insert(`nt_zone`, mapToDbColumn(args, zoneDbMap))) + return withZoneNameLock(args.zone, async (tx) => { + if (args.deleted !== true) await assertZoneNameAvailable(tx, args.zone) + return tx.execute(...tx.insert(`nt_zone`, mapToDbColumn(args, zoneDbMap))) + }) } async get(args) { @@ -192,6 +221,22 @@ class ZoneRepoMySQL extends ZoneBase { if (!args.id) return false const id = args.id delete args.id + + if (args.deleted === false) { + const rows = await Mysql.execute( + 'SELECT zone FROM nt_zone WHERE nt_zone_id = ? LIMIT 1', + [id], + ) + if (rows.length === 0) return false + return withZoneNameLock(rows[0].zone, async (tx) => { + await assertZoneNameAvailable(tx, rows[0].zone, id) + const r = await tx.execute( + ...tx.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), + ) + return r.changedRows === 1 + }) + } + const r = await Mysql.execute( ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), ) diff --git a/lib/zone/test/mysql.js b/lib/zone/test/mysql.js index b8ac9fc..c5031ad 100644 --- a/lib/zone/test/mysql.js +++ b/lib/zone/test/mysql.js @@ -5,8 +5,12 @@ import Zone from '../index.js' import baseCase from './zone.json' with { type: 'json' } -// Use a distinct id so this test never races with index.js (same fixture id = concurrent NULL mutation) -const testCase = { ...baseCase, id: 9001 } +const testCase = { + ...baseCase, + id: 9001, + zone: 'mysql-zone.example.com', + mailaddr: 'hostmaster.mysql-zone.example.com.', +} before(async () => { await Zone.destroy({ id: testCase.id }) diff --git a/routes/zone.js b/routes/zone.js index 11e7e3a..62ca241 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -1,6 +1,7 @@ import validate from '@nictool/validate' import Zone from '../lib/zone/index.js' +import { ZoneNameConflictError } from '../lib/zone/store/base.js' import Group from '../lib/group/index.js' import Authz from '../lib/authz/index.js' import Audit from '../lib/audit/index.js' @@ -120,20 +121,14 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const bare = request.payload.zone.replace(/\.$/, '') - const spellings = [...new Set([bare, `${bare}.`])] - const existing = await Promise.all(spellings.map((zone) => Zone.get({ zone }))) - if (existing.some((zones) => zones.length > 0)) { - return h - .response({ - zone: [], - meta: { api: meta.api, msg: `Zone is already taken` }, - }) - .code(409) + let id + try { + id = await Zone.create(request.payload) + } catch (err) { + if (err instanceof ZoneNameConflictError) return zoneNameConflict(h) + throw err } - const id = await Zone.create(request.payload) - const zones = await Zone.get({ id }) await Audit.logZone(request.auth.credentials.user, 'added', zones[0]) @@ -181,7 +176,12 @@ function ZoneRoutes(server) { const payload = Object.fromEntries( Object.entries(request.payload).filter(([key]) => ZONE_PUT_FIELDS.has(key)), ) - await Zone.put({ id, ...payload }) + try { + await Zone.put({ id, ...payload }) + } catch (err) { + if (err instanceof ZoneNameConflictError) return zoneNameConflict(h) + throw err + } let updated = await Zone.get({ id }) if (updated.length === 0) updated = await Zone.get({ id, deleted: true }) @@ -275,6 +275,13 @@ function zoneAuditAction(previous, payload) { return 'modified' } +function zoneNameConflict(h) { + return h.response({ + zone: [], + meta: { api: meta.api, msg: `Zone is already taken` }, + }).code(409) +} + export default ZoneRoutes export { Zone, ZoneRoutes } diff --git a/routes/zone.test.js b/routes/zone.test.js index 62f88f9..904d189 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -14,6 +14,9 @@ import nsCase from './test/zone.json' with { type: 'json' } let server let case2Id = 4094 const duplicateId = 4092 +const duplicateCaseId = 4089 +const concurrentDuplicateIds = [4087, 4088] +const recoveryDuplicateId = 4086 const subGroup = { id: 4090, parent_gid: groupCase.id, name: 'sub.route.example.com' } const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.example.com.' } @@ -22,6 +25,9 @@ before(async () => { await Zone.destroy({ id: nsCase.id }) await Zone.destroy({ id: case2Id }) await Zone.destroy({ id: duplicateId }) + await Zone.destroy({ id: duplicateCaseId }) + await Zone.destroy({ id: recoveryDuplicateId }) + for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) await Zone.destroy({ id: subZone.id }) // Destroy the subgroup before recreating it: a lingering row would make // Group.create early-return and skip addToSubgroups, leaving the @@ -41,6 +47,9 @@ before(async () => { after(async () => { await Zone.destroy({ id: duplicateId }) + await Zone.destroy({ id: duplicateCaseId }) + await Zone.destroy({ id: recoveryDuplicateId }) + for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) await Zone.destroy({ id: subZone.id }) await Group.destroy({ id: subGroup.id }) await Group.destroy({ id: case2Id }) @@ -98,6 +107,54 @@ describe('zone routes', () => { assert.match(res.result.meta.msg, /already taken/) }) + it('POST /zone rejects a case-only duplicate name', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { ...nsCase, id: duplicateCaseId, zone: `${nsCase.zone.toUpperCase()}.` }, + }) + await Zone.destroy({ id: duplicateCaseId }) + + assert.equal(res.statusCode, 409) + assert.deepEqual(res.result.zone, []) + }) + + it('POST /zone admits only one concurrent canonical name', async () => { + const responses = await Promise.all(concurrentDuplicateIds.map((id, index) => server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { + ...nsCase, + id, + zone: index === 0 ? 'concurrent.example.com' : 'CONCURRENT.EXAMPLE.COM.', + }, + }))) + for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) + + assert.deepEqual(responses.map((res) => res.statusCode).sort(), [201, 409]) + }) + + it('PUT /zone refuses recovery beside an active canonical name', async () => { + await Zone.create({ + ...nsCase, + id: recoveryDuplicateId, + zone: `${nsCase.zone.toUpperCase()}.`, + deleted: true, + }) + + const res = await server.inject({ + method: 'PUT', + url: `/zone/${recoveryDuplicateId}`, + headers: auth.headers, + payload: { deleted: false }, + }) + + assert.equal(res.statusCode, 409) + assert.equal((await Zone.get({ id: recoveryDuplicateId, deleted: true }))[0].deleted, true) + }) + it(`PUT /zone/${nsCase.id} moves it to another group`, async () => { const moved = await server.inject({ method: 'PUT', From 7de312999bbb454ebc260356abde3feeae7719b4 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:45 +0100 Subject: [PATCH 12/48] zone record: keep scoped reads, deletion audit --- lib/zone_record/store/file.js | 2 + routes/zone_record.js | 7 ++- routes/zone_record.test.js | 112 ++++++++++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/lib/zone_record/store/file.js b/lib/zone_record/store/file.js index 4ff5651..b0a80f9 100644 --- a/lib/zone_record/store/file.js +++ b/lib/zone_record/store/file.js @@ -47,6 +47,7 @@ class ZoneRecordRepoFile extends ZoneRecordBase { if (args.id !== undefined) records = records.filter((r) => r.id === args.id) if (args.zid !== undefined) records = records.filter((r) => r.zid === args.zid) + if (Array.isArray(args.ids)) records = records.filter((r) => args.ids.includes(r.id)) if (args.type !== undefined) records = records.filter((r) => r.type === args.type) if (deletedArg === false) records = records.filter((r) => !r.deleted) else if (deletedArg !== undefined) @@ -74,6 +75,7 @@ class ZoneRecordRepoFile extends ZoneRecordBase { if (args.id !== undefined) records = records.filter((r) => r.id === args.id) if (args.zid !== undefined) records = records.filter((r) => r.zid === args.zid) + if (Array.isArray(args.ids)) records = records.filter((r) => args.ids.includes(r.id)) if (args.type !== undefined) records = records.filter((r) => r.type === args.type) if (args.deleted === false) records = records.filter((r) => !r.deleted) else if (args.deleted !== undefined) diff --git a/routes/zone_record.js b/routes/zone_record.js index 2bbb78a..9a1dabc 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -221,11 +221,16 @@ function ZoneRecordRoutes(server) { .code(404) } + let zones = await Zone.get({ id: zrs[0].zid }) + if (zones.length === 0) zones = await Zone.get({ id: zrs[0].zid, deleted: true }) + if (zones.length === 0) { + return h.response({ meta: { api: meta.api, msg: `I couldn't find that zone` } }).code(404) + } + await ZoneRecord.delete({ id: zrs[0].id, deleted: 1, }) - const zones = await Zone.get({ id: zrs[0].zid }) await Audit.logZoneRecord( request.auth.credentials.user, 'deleted', diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index dd123e2..5e2b0df 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -6,6 +6,7 @@ import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' import ZoneRecord from '../lib/zone_record/index.js' +import Delegation from '../lib/delegation/index.js' import groupCase from './test/group.json' with { type: 'json' } import { grantGroupPermissions } from './test/permissions.js' @@ -18,6 +19,11 @@ const createdZoneRecordIds = [] const testGroupId = 5094 const testZoneId = 5095 const testZoneRecordId = 5096 +const deletedParentRecordId = 5097 +const delegatedGroupId = 5098 +const delegatedZoneId = 5099 +const delegatedRecordId = 5100 +const hiddenRecordId = 5101 const testZone = { ...zoneCase, @@ -35,8 +41,40 @@ const testZoneRecord = { address: '203.0.113.6', } +const deletedParentRecord = { + ...testZoneRecord, + id: deletedParentRecordId, + owner: 'deleted-parent.route-zr-delete.example.com.', +} + +const delegatedZone = { + ...zoneCase, + id: delegatedZoneId, + gid: delegatedGroupId, + zone: 'delegated-scope.route.example.com', +} + +const delegatedRecord = { + ...testZoneRecord, + id: delegatedRecordId, + zid: delegatedZoneId, + owner: 'allowed.delegated-scope.route.example.com.', +} + +const hiddenRecord = { + ...delegatedRecord, + id: hiddenRecordId, + owner: 'hidden.delegated-scope.route.example.com.', +} + before(async () => { + await Delegation.delete({ gid: testGroupId, oid: delegatedRecordId, type: 'ZONERECORD' }) + await ZoneRecord.destroy({ id: delegatedRecordId }) + await ZoneRecord.destroy({ id: hiddenRecordId }) + await Zone.destroy({ id: delegatedZoneId }) + await Group.destroy({ id: delegatedGroupId }) await ZoneRecord.destroy({ id: testZoneRecordId }) + await ZoneRecord.destroy({ id: deletedParentRecordId }) await Zone.destroy({ id: testZoneId }) const testGroup = { ...groupCase, id: testGroupId } @@ -53,17 +91,39 @@ before(async () => { await grantGroupPermissions(testGroup.id) await Zone.create(testZone) await ZoneRecord.create(testZoneRecord) + await ZoneRecord.create(deletedParentRecord) + await Group.create({ + ...groupCase, + id: delegatedGroupId, + name: 'delegated-owner', + }) + await Zone.create(delegatedZone) + await ZoneRecord.create(delegatedRecord) + await ZoneRecord.create(hiddenRecord) + await Delegation.create({ + gid: testGroupId, + oid: delegatedRecordId, + type: 'ZONERECORD', + delegated_by_id: testGroupId, + delegated_by_name: `route-zr-delete-${testGroupId}`, + }) server = await init() }) after(async () => { + await Delegation.delete({ gid: testGroupId, oid: delegatedRecordId, type: 'ZONERECORD' }) + await ZoneRecord.destroy({ id: delegatedRecordId }) + await ZoneRecord.destroy({ id: hiddenRecordId }) + await Zone.destroy({ id: delegatedZoneId }) + await Group.destroy({ id: delegatedGroupId }) for (const id of createdZoneRecordIds) { await ZoneRecord.destroy({ id }) } await ZoneRecord.destroy({ id: testZoneRecordId }) + await ZoneRecord.destroy({ id: deletedParentRecordId }) await Zone.destroy({ id: testZoneId }) - await server.stop() + if (server) await server.stop() }) describe('zone_record routes', () => { @@ -107,6 +167,28 @@ describe('zone_record routes', () => { createdZoneRecordIds.push(res.result.zone_record[0].id) }) + it('limits a delegated zone collection to delegated records', async () => { + const zones = await server.inject({ + method: 'GET', + url: `/zone?gid=${testGroupId}&search=delegated-scope`, + headers: auth.headers, + }) + assert.equal(zones.statusCode, 200) + assert.deepEqual(zones.result.zone.map((zone) => zone.id), [delegatedZoneId]) + assert.equal(zones.result.meta.pagination.total, 2) + assert.equal(zones.result.meta.pagination.filtered, 1) + + const records = await server.inject({ + method: 'GET', + url: `/zone_record?zid=${delegatedZoneId}`, + headers: auth.headers, + }) + assert.equal(records.statusCode, 200) + assert.deepEqual(records.result.zone_record.map((record) => record.id), [delegatedRecordId]) + assert.equal(records.result.meta.pagination.total, 1) + assert.equal(records.result.meta.pagination.filtered, 1) + }) + it('POST /zone_record accepts omitted ttl and stores 0', async () => { const res = await server.inject({ method: 'POST', @@ -154,16 +236,6 @@ describe('zone_record routes', () => { }) assert.equal(res.statusCode, 200) - if (res.result.zone_record.length !== 2) { - const all = await ZoneRecord.get({ zid: testZoneId }) - console.error('DIAGZR createdIds=', JSON.stringify(createdZoneRecordIds)) - console.error('DIAGZR limit-query result=', JSON.stringify(res.result.zone_record)) - console.error('DIAGZR filtered=', res.result.meta.pagination.filtered) - console.error( - 'DIAGZR all-records-for-zid=', - JSON.stringify(all.map((r) => ({ id: r.id, zid: r.zid, owner: r.owner, deleted: r.deleted }))), - ) - } assert.equal(res.result.zone_record.length, 2) assert.equal(res.result.zone_record[0].owner, `${token}0.route-zr-delete.example.com.`) assert.equal(res.result.meta.pagination.filtered, 3) @@ -227,6 +299,24 @@ describe('zone_record routes', () => { assert.equal(res.statusCode, 404) }) + it('DELETE /zone_record logs against a soft-deleted parent zone', async () => { + await Zone.delete({ id: testZoneId, deleted: true }) + let res + try { + res = await server.inject({ + method: 'DELETE', + url: `/zone_record/${deletedParentRecordId}`, + headers: auth.headers, + }) + } finally { + await Zone.delete({ id: testZoneId, deleted: false }) + } + + assert.equal(res.statusCode, 200) + assert.equal(res.result.zone_record[0].id, deletedParentRecordId) + assert.equal(res.result.zone_record[0].deleted, true) + }) + it('DELETE /session', async () => { const res = await server.inject({ method: 'DELETE', From 947132428399b217dac7d308759ff6aadb5e50e6 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:11:58 +0100 Subject: [PATCH 13/48] user: keep self_write from editing own permissions PUT /user/{self} applied any permission fields in the payload through Permission.put, so a self_write holder could grant themselves rights that /permission denies for self-targeted writes. Profile fields still apply; permission fields on a self edit are dropped. --- routes/authz.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++-- routes/user.js | 6 +++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/routes/authz.test.js b/routes/authz.test.js index c4fa850..23856c2 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -586,8 +586,51 @@ describe('authz plugin - zone record delegation', () => { assert.equal(record.zid, Z_INTREE.id) }) - it('does not require create permission when an edit repeats the current zone id', async () => { - const perm = await Permission.get({ uid: U_FULL.id }) + it('403 when moving a record out of a delegated zone without delete-record permission', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_OUTSIDE.id}`, + headers: authFull.headers, + payload: { zid: Z_INTREE.id }, + }) + assert.equal(res.statusCode, 403) + + const [record] = await ZoneRecord.get({ id: ZR_OUTSIDE.id }) + assert.equal(record.zid, Z_OUTSIDE.id) + }) + + it('moves a record between zones when both sides permit', async () => { + await Delegation.put({ + gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', + zone_perm_add_records: true, zone_perm_delete_records: true, + }) + try { + let res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE_OTHER.id}`, + headers: authFull.headers, + payload: { zid: Z_OUTSIDE.id }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await ZoneRecord.get({ id: ZR_INTREE_OTHER.id }))[0].zid, Z_OUTSIDE.id) + + res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE_OTHER.id}`, + headers: authFull.headers, + payload: { zid: Z_INTREE.id }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await ZoneRecord.get({ id: ZR_INTREE_OTHER.id }))[0].zid, Z_INTREE.id) + } finally { + await Delegation.put({ + gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', + zone_perm_add_records: false, zone_perm_delete_records: false, + }) + } + }) + + it('does not require create permission when an edit repeats the current zone id', async () => { const perm = await Permission.get({ uid: U_FULL.id }) await Permission.put({ id: perm.id, zonerecord_create: false }) try { const res = await server.inject({ @@ -1078,4 +1121,19 @@ describe('authz plugin - self permission inheritance', () => { assert.ok(after, 'the explicit permission row survives') assert.equal(after.id, before.id) }) + + it('strips permission fields from a self edit', async () => { + const before = await Permission.get({ uid: U_FULL.id }) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { first_name: 'Selfish', zone_write: false }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await User.get({ id: U_FULL.id }))[0].first_name, 'Selfish') + + const after = await Permission.get({ uid: U_FULL.id }) + assert.equal(after.zone.write, before.zone.write) + }) }) diff --git a/routes/user.js b/routes/user.js index 5ad2147..31b0f49 100644 --- a/routes/user.js +++ b/routes/user.js @@ -259,6 +259,12 @@ function UserRoutes(server) { const permFields = extractPermFields(request.payload) + // self_write grants profile edits only; own permissions change via /permission, + // which denies self-targeted writes + if (id === user.id) { + for (const field of Object.keys(permFields)) delete permFields[field] + } + request.payload = pickFields(request.payload, USER_PUT_FIELDS) // switching yourself back to inherited permissions adopts the group's, From 69c28a2124dee48c2a75a0d251d5d1a437c593e5 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:13:01 +0100 Subject: [PATCH 14/48] authz: moving a record out needs delete access Moving a zonerecord to another zid checked create permission on the destination only. A delegated-zone caller without zone_perm_delete_records could move records out and delete them in a zone they own. The source record now needs delete permission too; edits that repeat the current zid are unaffected. --- lib/authz-plugin.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index a255f94..f304438 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -112,11 +112,15 @@ const authzPlugin = { } } - if ( - permCfg.targetCreateResource - && request.payload?.zid !== undefined - && await changesTarget(request, resource, action, objectId) - ) { + const sourceZid = await movedFromZone(request, resource, action, objectId) + if (sourceZid !== null) { + // moving out of a zone needs delete rights where the record lives, + // not just create rights where it's going + const source = await Authz.checkPermission( + credentials, resource, 'delete', objectId, + ) + if (!source.allowed) return respond(source, h) + const target = await resolveTargetGroup(request, permCfg.targetCreateResource) const targetResult = await Authz.checkPermission( credentials, @@ -260,10 +264,12 @@ async function resolveTargetGroup(request, resource) { return null } -async function changesTarget(request, resource, action, objectId) { - if (resource !== 'zonerecord' || action !== 'write') return true +async function movedFromZone(request, resource, action, objectId) { + if (resource !== 'zonerecord' || action !== 'write') return null + if (objectId === undefined || request.payload?.zid === undefined) return null const records = await ZoneRecord.get({ id: objectId }) - return records.length === 0 || records[0].zid !== Number(request.payload.zid) + if (records.length === 0) return null + return records[0].zid !== Number(request.payload.zid) ? records[0].zid : null } export default authzPlugin From 28773ef0bde6b3b03e6f09e2d9ea6bb9ed7f469f Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:13:01 +0100 Subject: [PATCH 15/48] session: measure idle time from activity The 4h cutoff was measured from login: only GET /session refreshed last_access, and the token's own maxAgeSec was 4h, so active users were cut off mid-work either way. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/authz-plugin.js | 3 +++ routes/index.js | 2 +- routes/session.test.js | 20 ++++++++++++++++++++ routes/user.test.js | 30 +++++++++++++++++++++++++++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index f304438..36a6e5c 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -1,4 +1,5 @@ import Authz from './authz/index.js' +import Session from './session/index.js' import Zone from './zone/index.js' import ZoneRecord from './zone_record/index.js' @@ -28,6 +29,8 @@ const authzPlugin = { }).code(401).takeover() } request.auth.credentials = credentials + // idle expiry must measure activity; Session.put throttles to once a minute + Session.put({ id: credentials.session.id, last_access: true }) } const permCfg = request.route.settings.app?.permission diff --git a/routes/index.js b/routes/index.js index 701f745..4554c66 100644 --- a/routes/index.js +++ b/routes/index.js @@ -93,7 +93,7 @@ async function setup() { sub: false, nbf: true, exp: true, - maxAgeSec: 14400, // 4 hours + maxAgeSec: 28800, // the token's ttl; idle expiry is the session's job timeSkewSec: 15, }, httpAuthScheme: 'Bearer', diff --git a/routes/session.test.js b/routes/session.test.js index 019dfe3..8f881b1 100644 --- a/routes/session.test.js +++ b/routes/session.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { describe, it, before, after } from 'node:test' +import Jwt from '@hapi/jwt' + import { init } from './index.js' import userCase from './test/user.json' with { type: 'json' } import groupCase from './test/group.json' with { type: 'json' } @@ -9,6 +11,7 @@ import permCase from './test/permission.json' with { type: 'json' } import User from '../lib/user/index.js' import Group from '../lib/group/index.js' import Permission from '../lib/permission/index.js' +import Config from '../lib/config.js' let server @@ -58,6 +61,23 @@ describe('session routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) + it('honours a token issued five hours ago while its session is active', async () => { + const { user, group, session } = Jwt.token.decode(auth.headers.Authorization.replace('Bearer ', '')) + .decoded.payload.nt + const { jwt } = await Config.get('http') + const token = Jwt.token.generate( + { aud: 'urn:audience:test', iss: 'urn:issuer:test', nt: { user, group, session } }, + { key: jwt.key, algorithm: 'HS512' }, + { ttlSec: 28800, now: Date.now() - 5 * 3600 * 1000 }, + ) + const res = await server.inject({ + method: 'GET', + url: '/session', + headers: { Authorization: `Bearer ${token}` }, + }) + assert.equal(res.statusCode, 200) + }) + after(async () => { const res = await server.inject({ method: 'DELETE', diff --git a/routes/user.test.js b/routes/user.test.js index 31c0f3c..6b98a19 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -5,14 +5,17 @@ import { init } from './index.js' import User from '../lib/user/index.js' import Group from '../lib/group/index.js' import Permission from '../lib/permission/index.js' +import Session from '../lib/session/index.js' import Authz from '../lib/authz/index.js' +import Mysql from '../lib/mysql.js' import groupCase from './test/group.json' with { type: 'json' } import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } let server, - auth = { headers: {} } + auth = { headers: {} }, + sessionId before(async () => { server = await init() @@ -42,9 +45,34 @@ describe('user routes', () => { }, }) assert.ok(res.result.user.id) + sessionId = res.result.session.id auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) + it('authenticated requests refresh session activity', async () => { + const stale = Math.floor(Date.now() / 1000) - 7200 + await Mysql.execute( + 'UPDATE nt_user_session SET last_access = ? WHERE nt_user_session_id = ?', + [stale, sessionId], + ) + + const res = await server.inject({ + method: 'GET', + url: '/user', + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + + // the plugin's activity touch is fire-and-forget; give it a beat + let lastAccess = stale + for (let i = 0; i < 20 && lastAccess <= stale; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + const session = await Session.get({ id: sessionId }) + lastAccess = session?.last_access ?? stale + } + assert.ok(lastAccess > stale, 'last_access advanced past the stale value') + }) + it('GET /user', async () => { const res = await server.inject({ method: 'GET', From f62128f639693e0ec32b02f7ee1638d37767a534 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:06:34 +0100 Subject: [PATCH 16/48] test: keep session activity store-neutral --- routes/user.test.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/routes/user.test.js b/routes/user.test.js index 6b98a19..a987f08 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -7,7 +7,6 @@ import Group from '../lib/group/index.js' import Permission from '../lib/permission/index.js' import Session from '../lib/session/index.js' import Authz from '../lib/authz/index.js' -import Mysql from '../lib/mysql.js' import groupCase from './test/group.json' with { type: 'json' } import { grantGroupPermissions } from './test/permissions.js' @@ -49,12 +48,10 @@ describe('user routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) - it('authenticated requests refresh session activity', async () => { - const stale = Math.floor(Date.now() / 1000) - 7200 - await Mysql.execute( - 'UPDATE nt_user_session SET last_access = ? WHERE nt_user_session_id = ?', - [stale, sessionId], - ) + it('authenticated requests refresh session activity', async (t) => { + const sessionBefore = await Session.get({ id: sessionId }) + const now = sessionBefore.last_access + 7200 + t.mock.method(Date, 'now', () => now * 1000) const res = await server.inject({ method: 'GET', @@ -64,13 +61,13 @@ describe('user routes', () => { assert.equal(res.statusCode, 200) // the plugin's activity touch is fire-and-forget; give it a beat - let lastAccess = stale - for (let i = 0; i < 20 && lastAccess <= stale; i++) { + let lastAccess = sessionBefore.last_access + for (let i = 0; i < 20 && lastAccess <= sessionBefore.last_access; i++) { await new Promise((resolve) => setTimeout(resolve, 25)) const session = await Session.get({ id: sessionId }) - lastAccess = session?.last_access ?? stale + lastAccess = session?.last_access ?? sessionBefore.last_access } - assert.ok(lastAccess > stale, 'last_access advanced past the stale value') + assert.equal(lastAccess, now) }) it('GET /user', async () => { From 2fe42be6861606b36fab585a3c7f9344a2fba04c Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:08:58 +0100 Subject: [PATCH 17/48] bridge: enforce validate request contracts --- CHANGELOG.md | 5 + lib/audit/store/file.js | 34 +++++-- lib/file-stores.test.js | 87 ++++++++++++------ lib/user/qualified.js | 8 ++ lib/user/qualified.test.js | 20 ++++ lib/user/store/file.js | 18 +++- lib/user/store/mysql.js | 54 +++++------ lib/user/test/index.js | 33 +++++-- routes/authz.test.js | 182 ++++++++++++++++++++++++------------- routes/group.js | 35 ++++--- routes/group.test.js | 10 ++ routes/log.js | 41 ++++----- routes/session.js | 8 +- routes/session.test.js | 11 +++ routes/user.js | 61 ++++++++----- routes/user.test.js | 23 ++++- routes/zone.js | 21 +++-- routes/zone.test.js | 43 +++++++-- routes/zone_record.test.js | 19 +++- 19 files changed, 496 insertions(+), 217 deletions(-) create mode 100644 lib/user/qualified.js create mode 100644 lib/user/qualified.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3151a4c..4c8a9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +- audit: enforce route-specific queries and file-store exact matching +- session: validate qualified usernames and permission responses +- user: add subgroup group-name sorting and reject unknown writes +- group/zone: validate permission controls and zone serial updates + ### [3.0.3] - 2026-07-27 - many updates for data stores and NS backends diff --git a/lib/audit/store/file.js b/lib/audit/store/file.js index 4e04d6b..a4da949 100644 --- a/lib/audit/store/file.js +++ b/lib/audit/store/file.js @@ -63,8 +63,13 @@ class AuditRepoFile extends AuditBase { return page(rows, args, { searchKeys: ['user', 'action', 'object', 'title', 'description'], sortMap: { - timestamp: 'timestamp', user: 'user', action: 'action', object: 'object', - title: 'title', description: 'description', group_name: 'group_name', + timestamp: 'timestamp', + user: 'user', + action: 'action', + object: 'object', + title: 'title', + description: 'description', + group_name: 'group_name', }, }) } @@ -84,8 +89,13 @@ class AuditRepoFile extends AuditBase { return page(scoped, args, { searchKeys: ['zone', 'description', 'action', 'user', 'group_name'], sortMap: { - timestamp: 'timestamp', user: 'user', action: 'action', zone: 'zone', - ttl: 'ttl', description: 'description', group_name: 'group_name', + timestamp: 'timestamp', + user: 'user', + action: 'action', + zone: 'zone', + ttl: 'ttl', + description: 'description', + group_name: 'group_name', }, }) } @@ -103,8 +113,14 @@ class AuditRepoFile extends AuditBase { return page(rows, args, { searchKeys: ['owner', 'description', 'type', 'address', 'action', 'user'], sortMap: { - timestamp: 'timestamp', user: 'user', action: 'action', owner: 'owner', - type: 'type', address: 'address', ttl: 'ttl', weight: 'weight', + timestamp: 'timestamp', + user: 'user', + action: 'action', + owner: 'owner', + type: 'type', + address: 'address', + ttl: 'ttl', + weight: 'weight', description: 'description', }, }) @@ -141,7 +157,11 @@ async function page(rows, args, { searchKeys, sortMap }) { const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' if (search !== '') { rows = rows.filter((row) => - searchKeys.some((key) => String(row[key] ?? '').toLowerCase().includes(search))) + searchKeys.some((key) => { + const value = String(row[key] ?? '').toLowerCase() + return args.exact_match === true ? value === search : value.includes(search) + }), + ) } // LIKE treats % and _ as wildcards; here they match literally diff --git a/lib/file-stores.test.js b/lib/file-stores.test.js index bd1b8e5..b6ea85c 100644 --- a/lib/file-stores.test.js +++ b/lib/file-stores.test.js @@ -37,10 +37,14 @@ after(() => { describe('file-store delegation', () => { it('creates, lists, updates, and deletes a delegation', async () => { - const FileDelegation = new (await freshStores().then(([d]) => d.default)) + const FileDelegation = new (await freshStores().then(([d]) => d.default))() await seedEntity('group', [{ id: 42, name: 'delegate holder' }]) const created = await FileDelegation.create({ - gid: 42, oid: 7, type: 'ZONE', perm_write: true, delegated_by_id: 2, + gid: 42, + oid: 7, + type: 'ZONE', + perm_write: true, + delegated_by_id: 2, }) assert.ok(created.created) @@ -55,13 +59,15 @@ describe('file-store delegation', () => { assert.ok(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE', perm_write: false })) assert.equal((await FileDelegation.getDelegates(7, 'ZONE'))[0].delegate_write, 0) - assert.ok(await FileDelegation.delete({ - gid: 42, - oid: 7, - type: 'ZONE', - delegated_by_id: 9, - delegated_by_name: 'deleting user', - })) + assert.ok( + await FileDelegation.delete({ + gid: 42, + oid: 7, + type: 'ZONE', + delegated_by_id: 9, + delegated_by_name: 'deleting user', + }), + ) assert.equal(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE' }), null) assert.deepEqual(await FileDelegation.getDelegates(7, 'ZONE'), []) @@ -76,15 +82,25 @@ describe('file-store delegation', () => { describe('file-store audit', () => { it('records and lists entries with search, sort, and pagination', async () => { - const FileAudit = new (await freshStores().then(([, a]) => a.default)) + const FileAudit = new (await freshStores().then(([, a]) => a.default))() // the global listing resolves actors through the user file - await seedEntity('user', [{ - id: 5, gid: 3, username: 'auditor', first_name: 'Audie', last_name: 'Tor', - }]) + await seedEntity('user', [ + { + id: 5, + gid: 3, + username: 'auditor', + first_name: 'Audie', + last_name: 'Tor', + }, + ]) const actor = { id: 5 } const zone = { - id: 9, gid: 3, zone: 'audit.test.', mailaddr: 'hm.audit.test.', - serial: 1, ttl: 3600, + id: 9, + gid: 3, + zone: 'audit.test.', + mailaddr: 'hm.audit.test.', + serial: 1, + ttl: 3600, } await FileAudit.logZone(actor, 'added', zone) @@ -103,20 +119,26 @@ describe('file-store audit', () => { const searched = await FileAudit.listZones({ gids: [3], search: 'MODIF' }) assert.equal(searched.filtered, 1) + const exact = await FileAudit.listZones({ gids: [3], search: 'modified', exact_match: true }) + assert.equal(exact.filtered, 1) + + const partialExact = await FileAudit.listZones({ gids: [3], search: 'mod', exact_match: true }) + assert.equal(partialExact.filtered, 0) + const global = await FileAudit.listGlobal({ gids: [3] }) assert.equal(global.total, 2) assert.equal(global.rows[0].description, 'modified zone') }) it('rejects an unscoped listing', async () => { - const FileAudit = new (await freshStores().then(([, a]) => a.default)) + const FileAudit = new (await freshStores().then(([, a]) => a.default))() assert.equal((await FileAudit.listZones({ gids: [] })).total, 0) }) }) describe('file-store authz', () => { it('resolves object groups and group trees', async () => { - const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) + const FileAuthz = new (await freshStores().then(([, , z]) => z.default))() await seedEntity('group', [ { id: 1, name: 'root' }, { id: 10, name: 'parent', parent_gid: 1 }, @@ -134,38 +156,47 @@ describe('file-store authz', () => { }) it('uses the persisted session and permission shapes', async () => { - const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) + const FileAuthz = new (await freshStores().then(([, , z]) => z.default))() await seedEntity('group', [ { id: 10, name: 'parent', permissions: { id: 10 } }, { id: 11, name: 'deleted-owner', parent_gid: 10 }, ]) - await seedEntity('user', [ - { id: 30, gid: 10, username: 'u', permissions: { id: 30 } }, - ]) + await seedEntity('user', [{ id: 30, gid: 10, username: 'u', permissions: { id: 30 } }]) await seedEntity('session', [{ id: 40, uid: 30, last_access: 2_000_000_000 }]) await seedEntity('zone', [{ id: 50, gid: 11, zone: 'deleted.test.', deleted: true }]) assert.equal(await FileAuthz.liveSessionGroup(30, 40, 1_000_000_000), 10) assert.deepEqual(await FileAuthz.permissionRecord(30), { - uid: 30, gid: 10, target_gid: 10, + uid: 30, + gid: 10, + target_gid: 10, }) assert.equal(await FileAuthz.getObjectGroupId('zone', 50), 11) }) it('honors delegations for record access', async () => { - const FileAuthz = new (await freshStores().then(([, , z]) => z.default)) - const FileDelegation = new (await freshStores().then(([d]) => d.default)) + const FileAuthz = new (await freshStores().then(([, , z]) => z.default))() + const FileDelegation = new (await freshStores().then(([d]) => d.default))() - await seedEntity('group', [{ id: 50, name: 'holder' }, { id: 51, name: 'owner' }]) + await seedEntity('group', [ + { id: 50, name: 'holder' }, + { id: 51, name: 'owner' }, + ]) await seedEntity('zone', [{ id: 60, gid: 51, zone: 'del.test.' }]) await seedEntity('zone_record', [{ id: 61, zid: 60, owner: 'a.del.test.' }]) await FileDelegation.create({ - gid: 50, oid: 61, type: 'ZONERECORD', - perm_write: true, zone_perm_add_records: true, + gid: 50, + oid: 61, + type: 'ZONERECORD', + perm_write: true, + zone_perm_add_records: true, }) // a delegation on the zone itself surfaces when acting on its records await FileDelegation.create({ - gid: 50, oid: 60, type: 'ZONE', perm_write: true, + gid: 50, + oid: 60, + type: 'ZONE', + perm_write: true, }) assert.deepEqual(await FileAuthz.delegatedRecordIdsInZone(50, 60), [61]) diff --git a/lib/user/qualified.js b/lib/user/qualified.js new file mode 100644 index 0000000..7970168 --- /dev/null +++ b/lib/user/qualified.js @@ -0,0 +1,8 @@ +export function splitQualifiedUsername(value, defaultGroup) { + const separator = value.indexOf('@') + if (separator === -1) return { username: value, groupName: defaultGroup } + return { + username: value.slice(0, separator), + groupName: value.slice(separator + 1), + } +} diff --git a/lib/user/qualified.test.js b/lib/user/qualified.test.js new file mode 100644 index 0000000..fcf077f --- /dev/null +++ b/lib/user/qualified.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { splitQualifiedUsername } from './qualified.js' + +describe('qualified usernames', () => { + it('uses the configured group for an unqualified username', () => { + assert.deepEqual(splitQualifiedUsername('alice', 'NicTool'), { + username: 'alice', + groupName: 'NicTool', + }) + }) + + it('preserves @ characters in the group name', () => { + assert.deepEqual(splitQualifiedUsername('alice@Operations@London', 'NicTool'), { + username: 'alice', + groupName: 'Operations@London', + }) + }) +}) diff --git a/lib/user/store/file.js b/lib/user/store/file.js index 1918d17..cf7c761 100644 --- a/lib/user/store/file.js +++ b/lib/user/store/file.js @@ -3,6 +3,7 @@ import Config from '../../config.js' import Credentials from '../credentials.js' import Permission from '../../permission/index.js' import UserBase from './base.js' +import { splitQualifiedUsername } from '../qualified.js' const boolFields = ['is_admin', 'deleted'] let userWriteQueue = Promise.resolve() @@ -10,7 +11,9 @@ let userWriteQueue = Promise.resolve() async function withUserWriteLock(fn) { const previous = userWriteQueue let release - userWriteQueue = new Promise((resolve) => { release = resolve }) + userWriteQueue = new Promise((resolve) => { + release = resolve + }) await previous try { return await fn() @@ -68,13 +71,15 @@ class UserRepoFile extends UserBase { } async authenticate(authTry) { - let [username, groupName] = authTry.username.split('@') - if (!groupName) groupName = this.cfg.group ?? 'NicTool' + const { username, groupName } = splitQualifiedUsername(authTry.username, this.cfg.group ?? 'NicTool') + const groups = await this.groupFile.load('group') const users = await this._load() for (const u of users) { if (u.username !== username) continue if (u.deleted) continue + const group = groups.find((g) => g.id === u.gid && g.name === groupName && !g.deleted) + if (!group) continue const { valid, needsUpgrade } = await Credentials.validPassword( authTry.password, @@ -96,7 +101,7 @@ class UserRepoFile extends UserBase { const result = { ...u } for (const f of ['password', 'pass_salt', 'permissions']) delete result[f] - const g = { id: result.gid, name: groupName } + const g = { id: result.gid, name: group.name } delete result.gid return { user: result, group: g } } @@ -108,6 +113,9 @@ class UserRepoFile extends UserBase { const deletedArg = args.deleted ?? false let users = await this._load() + const groups = await this.groupFile.load('group') + const groupNames = new Map(groups.map((group) => [group.id, group.name])) + users = users.map((u) => ({ ...u, group_name: groupNames.get(u.gid) ?? '' })) if (args.id !== undefined) users = users.filter((u) => u.id === args.id) if (args.gid !== undefined) { @@ -126,7 +134,7 @@ class UserRepoFile extends UserBase { }) } - const sortBy = ['id', 'username', 'email', 'first_name', 'last_name'].includes(args.sort_by) + const sortBy = ['id', 'username', 'email', 'first_name', 'last_name', 'group_name'].includes(args.sort_by) ? args.sort_by : 'username' const direction = args.sort_dir === 'desc' ? -1 : 1 diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 50fc494..2aae7d5 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -4,6 +4,7 @@ import Credentials from '../credentials.js' import UserBase from './base.js' import Permission from '../../permission/index.js' import { mapToDbColumn } from '../../util.js' +import { splitQualifiedUsername } from '../qualified.js' const userDbMap = { id: 'nt_user_id', gid: 'nt_group_id' } const boolFields = ['is_admin', 'deleted'] @@ -16,8 +17,7 @@ class UserRepoMySQL extends UserBase { } async authenticate(authTry) { - let [username, groupName] = authTry.username.split('@') - if (!groupName) groupName = this.cfg.group ?? 'NicTool' + const { username, groupName } = splitQualifiedUsername(authTry.username, this.cfg.group ?? 'NicTool') const query = `SELECT u.nt_user_id AS id , u.nt_group_id @@ -115,11 +115,12 @@ class UserRepoMySQL extends UserBase { delete args.exact_match const sortByMap = { - id: 'nt_user_id', - username: 'username', - email: 'email', - first_name: 'first_name', - last_name: 'last_name', + id: 'u.nt_user_id', + username: 'u.username', + email: 'u.email', + first_name: 'u.first_name', + last_name: 'u.last_name', + group_name: 'g.name', } const sortBy = sortByMap[args.sort_by] ?? 'username' const sortDir = args.sort_dir === 'desc' ? 'DESC' : 'ASC' @@ -131,15 +132,16 @@ class UserRepoMySQL extends UserBase { const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 delete args.offset - let query = `SELECT email - , first_name - , last_name - , nt_group_id AS gid - , nt_user_id AS id - , username - , email - , deleted - FROM nt_user` + let query = `SELECT u.email + , u.first_name + , u.last_name + , u.nt_group_id AS gid + , u.nt_user_id AS id + , u.username + , u.deleted + , g.name AS group_name + FROM nt_user u + JOIN nt_group g ON g.nt_group_id = u.nt_group_id` const params = [] const where = [] @@ -152,34 +154,34 @@ class UserRepoMySQL extends UserBase { [args.gid], ) const gids = [args.gid, ...subgroupRows.map((r) => r.nt_subgroup_id)] - where.push(`nt_group_id IN (${gids.join(',')})`) + where.push(`u.nt_group_id IN (${gids.join(',')})`) } else { - where.push('nt_group_id = ?') + where.push('u.nt_group_id = ?') params.push(args.gid) } delete args.gid } if (args.id) { - where.push('nt_user_id = ?') + where.push('u.nt_user_id = ?') params.push(args.id) delete args.id } if (args.username) { - where.push('username = ?') + where.push('u.username = ?') params.push(args.username) delete args.username } if (args.deleted !== undefined) { - where.push('deleted = ?') + where.push('u.deleted = ?') params.push(args.deleted === true ? 1 : 0) delete args.deleted } if (search) { - where.push(`username ${exactMatch ? '=' : 'LIKE'} ?`) + where.push(`u.username ${exactMatch ? '=' : 'LIKE'} ?`) params.push(exactMatch ? search : `%${search}%`) } @@ -296,10 +298,10 @@ class UserRepoMySQL extends UserBase { return Mysql.transaction(async (tx) => { const r = await tx.execute(...tx.update(`nt_user`, `nt_user_id=${id}`, userArgs)) - await tx.execute( - 'UPDATE nt_perm SET nt_group_id = ? WHERE nt_user_id = ? AND deleted = 0', - [args.gid, id], - ) + await tx.execute('UPDATE nt_perm SET nt_group_id = ? WHERE nt_user_id = ? AND deleted = 0', [ + args.gid, + id, + ]) return r.changedRows === 1 }) } diff --git a/lib/user/test/index.js b/lib/user/test/index.js index 1953659..ab1bef9 100644 --- a/lib/user/test/index.js +++ b/lib/user/test/index.js @@ -35,6 +35,7 @@ function sanitize(u) { for (const f of ['password', 'pass_salt', 'permissions', 'inherit_group_permissions', 'deleted']) { delete r[f] } + if (r.gid === groupCase.id) r.group_name = groupCase.name return r } @@ -89,12 +90,14 @@ describe('user', function () { it('searches, sorts, and paginates users', async () => { const ids = [] for (const username of ['unit-search-alpha', 'unit-search-beta', 'unit-search-gamma']) { - ids.push(await User.create({ - ...userCase, - id: undefined, - username, - email: `${username}@example.com`, - })) + ids.push( + await User.create({ + ...userCase, + id: undefined, + username, + email: `${username}@example.com`, + }), + ) } try { const exact = await User.get({ @@ -102,7 +105,10 @@ describe('user', function () { search: 'unit-search-beta', exact_match: true, }) - assert.deepEqual(exact.map((u) => u.username), ['unit-search-beta']) + assert.deepEqual( + exact.map((u) => u.username), + ['unit-search-beta'], + ) const page = await User.get({ gid: userCase.gid, @@ -112,7 +118,10 @@ describe('user', function () { limit: 2, offset: 1, }) - assert.deepEqual(page.map((u) => u.username), ['unit-search-beta', 'unit-search-alpha']) + assert.deepEqual( + page.map((u) => u.username), + ['unit-search-beta', 'unit-search-alpha'], + ) assert.equal(await User.count({ gid: userCase.gid, search: 'unit-search-' }), 3) } finally { for (const id of ids) await User.destroy({ id }) @@ -217,5 +226,13 @@ describe('user', function () { }) assert.ok(u) }) + + it('rejects a valid user in the wrong group', async () => { + const u = await User.authenticate({ + username: `${userCase.username}@wrong.example.com`, + password: userCase.password, + }) + assert.equal(u, undefined) + }) }) }) diff --git a/routes/authz.test.js b/routes/authz.test.js index 23856c2..2f8fcc2 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -136,12 +136,21 @@ const authLimited = { headers: {} } before(async () => { // Clean up stale data from prior crashed runs - try { await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) } - catch { /* ignore */ } - try { await Delegation.delete({ gid: 4201, oid: 4200, type: 'ZONE' }) } - catch { /* ignore */ } - try { await Delegation.delete({ gid: 4201, oid: 4201, type: 'ZONE' }) } - catch { /* ignore */ } + try { + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + } catch { + /* ignore */ + } + try { + await Delegation.delete({ gid: 4201, oid: 4200, type: 'ZONE' }) + } catch { + /* ignore */ + } + try { + await Delegation.delete({ gid: 4201, oid: 4201, type: 'ZONE' }) + } catch { + /* ignore */ + } await ZoneRecord.destroy({ id: ZR_DELEGATED_CREATE.id }) for (const id of [4200, 4201, ZR_INTREE_OTHER.id, U_CREATED.id]) { await ZoneRecord.destroy({ id }) @@ -154,10 +163,7 @@ before(async () => { await User.destroy({ id }) } for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) - await Mysql.execute( - 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', - [4200, 4201, 4202], - ) + await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) for (const u of [U_FULL, U_LIMITED]) await User.create(u) @@ -168,12 +174,23 @@ before(async () => { await Permission.put({ id: fullPerm.id, self_write: 1, - group_write: 1, group_create: 1, group_delete: 1, - zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, - zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + group_write: 1, + group_create: 1, + group_delete: 1, + zone_write: 1, + zone_create: 1, + zone_delete: 1, + zone_delegate: 1, + zonerecord_write: 1, + zonerecord_create: 1, + zonerecord_delete: 1, zonerecord_delegate: 1, - user_write: 1, user_create: 1, user_delete: 1, - nameserver_write: 1, nameserver_create: 1, nameserver_delete: 1, + user_write: 1, + user_create: 1, + user_delete: 1, + nameserver_write: 1, + nameserver_create: 1, + nameserver_delete: 1, usable_ns: '4200', }) } @@ -184,12 +201,23 @@ before(async () => { await Permission.put({ id: limPerm.id, self_write: 0, - group_write: 0, group_create: 0, group_delete: 0, - zone_write: 0, zone_create: 0, zone_delete: 0, zone_delegate: 0, - zonerecord_write: 0, zonerecord_create: 0, zonerecord_delete: 0, + group_write: 0, + group_create: 0, + group_delete: 0, + zone_write: 0, + zone_create: 0, + zone_delete: 0, + zone_delegate: 0, + zonerecord_write: 0, + zonerecord_create: 0, + zonerecord_delete: 0, zonerecord_delegate: 0, - user_write: 0, user_create: 0, user_delete: 0, - nameserver_write: 0, nameserver_create: 0, nameserver_delete: 0, + user_write: 0, + user_create: 0, + user_delete: 0, + nameserver_write: 0, + nameserver_create: 0, + nameserver_delete: 0, usable_ns: '', }) } @@ -203,8 +231,12 @@ before(async () => { // Delegation: zone 4201 → group 4200, write=yes delete=no await Delegation.create({ - gid: 4200, oid: 4201, type: 'ZONE', - perm_write: true, perm_delete: false, perm_delegate: true, + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_write: true, + perm_delete: false, + perm_delegate: true, }) server = await init() @@ -261,10 +293,7 @@ after(async () => { for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { await Group.destroy({ id: g.id }) } - await Mysql.execute( - 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', - [4200, 4201, 4202], - ) + await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) await Mysql.disconnect() }) @@ -304,7 +333,10 @@ describe('authz plugin - zone routes', () => { headers: authLimited.headers, }) assert.equal(res.statusCode, 200) - assert.deepEqual(res.result.zone.map((z) => z.id), [Z_OUTSIDE.id]) + assert.deepEqual( + res.result.zone.map((z) => z.id), + [Z_OUTSIDE.id], + ) assert.equal(res.result.meta.pagination.total, 1) }) @@ -378,18 +410,19 @@ describe('authz plugin - zone routes', () => { assert.equal(zone.gid, G_ROOT.id) }) - it('does not pass unknown fields to the zone store', async () => { + it('rejects unknown zone fields before reaching the store', async () => { + const [before] = await Zone.get({ id: Z_INTREE.id }) const res = await server.inject({ method: 'PUT', url: `/zone/${Z_INTREE.id}`, headers: authFull.headers, payload: { ttl: 7201, serial: 7, malicious: 'not-a-column' }, }) - assert.equal(res.statusCode, 200) + assert.equal(res.statusCode, 400) const [zone] = await Zone.get({ id: Z_INTREE.id }) - assert.equal(zone.ttl, 7201) - assert.equal(zone.serial, 7) + assert.equal(zone.ttl, before.ttl) + assert.equal(zone.serial, before.serial) }) it('403 for POST /zone when the requested id already exists', async () => { @@ -443,11 +476,20 @@ describe('authz plugin - user self-ops', () => { assert.equal((await User.get({ id: U_FULL.id }))[0].gid, G_ROOT.id) }) - it('does not pass unknown fields or is_admin through self-write', async () => { - const [before] = await Mysql.execute( - 'SELECT is_admin FROM nt_user WHERE nt_user_id = ?', - [U_FULL.id], - ) + it('rejects unknown fields before self-write reaches the store', async () => { + const [before] = await User.get({ id: U_FULL.id }) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { first_name: 'Rejected Full', malicious: 'not-a-column' }, + }) + assert.equal(res.statusCode, 400) + assert.equal((await User.get({ id: U_FULL.id }))[0].first_name, before.first_name) + }) + + it('does not pass is_admin through self-write', async () => { + const [before] = await Mysql.execute('SELECT is_admin FROM nt_user WHERE nt_user_id = ?', [U_FULL.id]) const res = await server.inject({ method: 'PUT', url: `/user/${U_FULL.id}`, @@ -455,7 +497,6 @@ describe('authz plugin - user self-ops', () => { payload: { first_name: 'Still Full', is_admin: true, - malicious: 'not-a-column', }, }) assert.equal(res.statusCode, 200) @@ -500,10 +541,7 @@ describe('authz plugin - user self-ops', () => { }) assert.equal(res.statusCode, 201) - const [stored] = await Mysql.execute( - 'SELECT is_admin FROM nt_user WHERE nt_user_id = ?', - [U_CREATED.id], - ) + const [stored] = await Mysql.execute('SELECT is_admin FROM nt_user WHERE nt_user_id = ?', [U_CREATED.id]) assert.equal(stored.is_admin, null) }) }) @@ -527,10 +565,7 @@ describe('authz plugin - group self-ops', () => { headers: authFull.headers, }) assert.equal(res.statusCode, 403) - assert.match( - res.result.error_msg, - /Not allowed to delete your own group/, - ) + assert.match(res.result.error_msg, /Not allowed to delete your own group/) }) it('403 when moving a group beneath itself', async () => { @@ -601,8 +636,11 @@ describe('authz plugin - zone record delegation', () => { it('moves a record between zones when both sides permit', async () => { await Delegation.put({ - gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', - zone_perm_add_records: true, zone_perm_delete_records: true, + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + zone_perm_add_records: true, + zone_perm_delete_records: true, }) try { let res = await server.inject({ @@ -624,13 +662,17 @@ describe('authz plugin - zone record delegation', () => { assert.equal((await ZoneRecord.get({ id: ZR_INTREE_OTHER.id }))[0].zid, Z_INTREE.id) } finally { await Delegation.put({ - gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', - zone_perm_add_records: false, zone_perm_delete_records: false, + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + zone_perm_add_records: false, + zone_perm_delete_records: false, }) } }) - it('does not require create permission when an edit repeats the current zone id', async () => { const perm = await Permission.get({ uid: U_FULL.id }) + it('does not require create permission when an edit repeats the current zone id', async () => { + const perm = await Permission.get({ uid: U_FULL.id }) await Permission.put({ id: perm.id, zonerecord_create: false }) try { const res = await server.inject({ @@ -775,7 +817,10 @@ describe('authz plugin - delegation routes', () => { it('perm_delete permits removal, never deletion of the delegated zone', async () => { await Delegation.put({ - gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', perm_delete: true, + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + perm_delete: true, }) const res = await server.inject({ method: 'DELETE', @@ -784,7 +829,10 @@ describe('authz plugin - delegation routes', () => { }) assert.equal(res.statusCode, 403) await Delegation.put({ - gid: G_ROOT.id, oid: Z_OUTSIDE.id, type: 'ZONE', perm_delete: false, + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + perm_delete: false, }) }) }) @@ -798,8 +846,6 @@ describe('authz plugin - create target resolution', () => { }) it('authorizes the group a new group is actually filed under', async () => { - // gid is not the key Group.create reads; authorizing it would let - // parent_gid point anywhere const res = await server.inject({ method: 'POST', url: '/group', @@ -807,7 +853,6 @@ describe('authz plugin - create target resolution', () => { payload: { id: G_PLANTED, name: 'authz-planted', - gid: G_ROOT.id, parent_gid: G_OUTSIDE.id, }, }) @@ -820,7 +865,7 @@ describe('authz plugin - create target resolution', () => { method: 'POST', url: '/group', headers: authFull.headers, - payload: { id: G_PLANTED, name: 'authz-rootless', gid: G_ROOT.id }, + payload: { id: G_PLANTED, name: 'authz-rootless' }, }) assert.equal(res.statusCode, 403) assert.match(res.result.error_msg, /No target group/) @@ -912,7 +957,10 @@ describe('authz plugin - permission records', () => { assert.equal(after.gid ?? after.group.id, G_CHILD.id) await Permission.put({ - id: perm.id, zone_create: 0, zone_write: 0, self_write: 0, + id: perm.id, + zone_create: 0, + zone_write: 0, + self_write: 0, }) }) @@ -929,8 +977,7 @@ describe('authz plugin - permission records', () => { } // direct SQL: Permission.get throws when a crashed run left two rows behind - const clearTarget = () => - Mysql.execute('DELETE FROM nt_perm WHERE nt_user_id = ?', [U_TARGET.id]) + const clearTarget = () => Mysql.execute('DELETE FROM nt_perm WHERE nt_user_id = ?', [U_TARGET.id]) before(async () => { await clearTarget() @@ -1042,8 +1089,12 @@ describe('authz plugin - delegation type and pseudo access', () => { it('grants read on a zone holding a record delegated to the caller', async () => { // limited user's group has no access to zone 4200, only to one record in it await Delegation.create({ - gid: G_OUTSIDE.id, oid: ZR_INTREE.id, type: 'ZONERECORD', - perm_write: false, perm_delete: false, perm_delegate: false, + gid: G_OUTSIDE.id, + oid: ZR_INTREE.id, + type: 'ZONERECORD', + perm_write: false, + perm_delete: false, + perm_delegate: false, }) try { const res = await server.inject({ @@ -1070,7 +1121,10 @@ describe('authz plugin - delegation type and pseudo access', () => { headers: authLimited.headers, }) assert.equal(records.statusCode, 200) - assert.deepEqual(records.result.zone_record.map((record) => record.id), [ZR_INTREE.id]) + assert.deepEqual( + records.result.zone_record.map((record) => record.id), + [ZR_INTREE.id], + ) assert.equal(records.result.meta.pagination.total, 1) const write = await server.inject({ @@ -1082,7 +1136,9 @@ describe('authz plugin - delegation type and pseudo access', () => { assert.equal(write.statusCode, 403) } finally { await Delegation.delete({ - gid: G_OUTSIDE.id, oid: ZR_INTREE.id, type: 'ZONERECORD', + gid: G_OUTSIDE.id, + oid: ZR_INTREE.id, + type: 'ZONERECORD', }) } }) diff --git a/routes/group.js b/routes/group.js index 70b1d9a..dcb49e0 100644 --- a/routes/group.js +++ b/routes/group.js @@ -8,21 +8,30 @@ import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' const PERM_FIELDS = new Set([ - 'group_write', 'group_create', 'group_delete', - 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', - 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', - 'user_write', 'user_create', 'user_delete', - 'nameserver_write', 'nameserver_create', 'nameserver_delete', - 'self_write', 'usable_ns', + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', + 'self_write', + 'usable_ns', ]) -const GROUP_POST_FIELDS = new Set([ - 'id', 'name', 'parent_gid', 'deleted', 'usable_ns', -]) +const GROUP_POST_FIELDS = new Set(['id', 'name', 'parent_gid', 'deleted', 'usable_ns']) -const GROUP_PUT_FIELDS = new Set([ - 'name', 'parent_gid', 'deleted', 'usable_ns', -]) +const GROUP_PUT_FIELDS = new Set(['name', 'parent_gid', 'deleted', 'usable_ns']) function extractPermFields(payload) { const permFields = {} @@ -117,7 +126,6 @@ function GroupRoutes(server) { app: { permission: { resource: 'group', action: 'create' } }, validate: { payload: validate.group.POST, - options: { allowUnknown: true }, }, response: { schema: validate.group.GET_res, @@ -165,7 +173,6 @@ function GroupRoutes(server) { }, validate: { payload: validate.group.PUT, - options: { allowUnknown: true }, }, response: { schema: validate.group.GET_res, diff --git a/routes/group.test.js b/routes/group.test.js index 347a6ac..0fba06d 100644 --- a/routes/group.test.js +++ b/routes/group.test.js @@ -82,6 +82,16 @@ describe('group routes', () => { assert.equal(res.result.group[0].id, case2Id) }) + it(`PUT /group/${case2Id} rejects unknown permission controls`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${case2Id}`, + headers: auth.headers, + payload: { definitely_not_a_permission: true }, + }) + assert.equal(res.statusCode, 400) + }) + it(`DELETE /group/${case2Id}`, async () => { const res = await server.inject({ method: 'DELETE', diff --git a/routes/log.js b/routes/log.js index 55ddfe1..d29f9dc 100644 --- a/routes/log.js +++ b/routes/log.js @@ -9,7 +9,7 @@ function LogRoutes(server) { { method: 'GET', path: '/log/global', - options: groupLogOptions(), + options: groupLogOptions(validate.log.GET_global_req), handler: async (request, h) => { const gids = await groupScope(request) return logResponse(h, await Audit.listGlobal({ ...request.query, gids })) @@ -18,7 +18,7 @@ function LogRoutes(server) { { method: 'GET', path: '/log/zone', - options: groupLogOptions(), + options: groupLogOptions(validate.log.GET_zone_req), handler: async (request, h) => { const gids = await groupScope(request) return logResponse(h, await Audit.listZones({ ...request.query, gids })) @@ -29,19 +29,16 @@ function LogRoutes(server) { path: '/log/zone_record', options: { app: { permission: { resource: 'zone', action: 'read', idFrom: 'query.zid' } }, - validate: { query: validate.log.GET_req }, + validate: { query: validate.log.GET_zone_record_req }, response: { schema: validate.log.GET_res }, tags: ['api'], }, - handler: async (request, h) => logResponse( - h, - await Audit.listZoneRecords(request.query), - ), + handler: async (request, h) => logResponse(h, await Audit.listZoneRecords(request.query)), }, ]) } -function groupLogOptions() { +function groupLogOptions(querySchema) { return { app: { permission: { @@ -50,7 +47,7 @@ function groupLogOptions() { list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, }, }, - validate: { query: validate.log.GET_req }, + validate: { query: querySchema }, response: { schema: validate.log.GET_res }, tags: ['api'], } @@ -62,19 +59,21 @@ async function groupScope(request) { } function logResponse(h, result) { - return h.response({ - log: result.rows, - meta: { - api: meta.api, - msg: 'audit entries', - pagination: { - total: result.total, - filtered: result.filtered, - limit: result.limit, - offset: result.offset, + return h + .response({ + log: result.rows, + meta: { + api: meta.api, + msg: 'audit entries', + pagination: { + total: result.total, + filtered: result.filtered, + limit: result.limit, + offset: result.offset, + }, }, - }, - }).code(200) + }) + .code(200) } export default LogRoutes diff --git a/routes/session.js b/routes/session.js index 1c06c12..814bc09 100644 --- a/routes/session.js +++ b/routes/session.js @@ -19,7 +19,6 @@ function SessionRoutes(server) { options: { response: { schema: validate.session.GET_res, - options: { allowUnknown: true }, }, tags: ['api'], }, @@ -30,7 +29,8 @@ function SessionRoutes(server) { const perm = await Permission.getEffective(user.id) const groupPerm = await Permission.getGroup({ - uid: user.id, deleted: false, + uid: user.id, + deleted: false, }) if (perm && groupPerm) { perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] @@ -60,7 +60,6 @@ function SessionRoutes(server) { }, response: { schema: validate.session.GET_res, - options: { allowUnknown: true }, }, tags: ['api'], }, @@ -97,7 +96,8 @@ function SessionRoutes(server) { const perm = await Permission.getEffective(account.user.id) const groupPerm = await Permission.getGroup({ - uid: account.user.id, deleted: false, + uid: account.user.id, + deleted: false, }) if (perm && groupPerm) { perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] diff --git a/routes/session.test.js b/routes/session.test.js index 8f881b1..ab56aec 100644 --- a/routes/session.test.js +++ b/routes/session.test.js @@ -111,4 +111,15 @@ describe('session routes', () => { }) } }) + + it('rejects malformed qualified usernames', async () => { + for (const username of ['a'.repeat(51), `@${groupCase.name}`, 'valid@x', 'valid@-invalid']) { + const res = await server.inject({ + method: 'POST', + url: '/session', + payload: { username, password: userCase.password }, + }) + assert.equal(res.statusCode, 400, username) + } + }) }) diff --git a/routes/user.js b/routes/user.js index 31b0f49..69c3f16 100644 --- a/routes/user.js +++ b/routes/user.js @@ -8,22 +8,47 @@ import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' const PERM_FIELDS = new Set([ - 'group_write', 'group_create', 'group_delete', - 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', - 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', - 'user_write', 'user_create', 'user_delete', - 'nameserver_write', 'nameserver_create', 'nameserver_delete', - 'self_write', 'usable_ns', + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', + 'self_write', + 'usable_ns', ]) const USER_POST_FIELDS = new Set([ - 'id', 'gid', 'first_name', 'last_name', 'username', 'email', 'password', + 'id', + 'gid', + 'first_name', + 'last_name', + 'username', + 'email', + 'password', 'inherit_group_permissions', ]) const USER_PUT_FIELDS = new Set([ - 'gid', 'first_name', 'last_name', 'username', 'email', 'password', - 'deleted', 'inherit_group_permissions', + 'gid', + 'first_name', + 'last_name', + 'username', + 'email', + 'password', + 'deleted', + 'inherit_group_permissions', ]) function extractPermFields(payload) { @@ -156,7 +181,8 @@ function UserRoutes(server) { const gid = prepareUserResponse(users[0]) const groupPerm = await Permission.getGroup({ - uid: getArgs.id, deleted: false, + uid: getArgs.id, + deleted: false, }) if (users[0].permissions && groupPerm) { users[0].permissions.nameserver.usable = groupPerm.nameserver?.usable ?? [] @@ -181,7 +207,6 @@ function UserRoutes(server) { app: { permission: { resource: 'user', action: 'create' } }, validate: { payload: validate.user.POST, - options: { allowUnknown: true }, }, response: { schema: validate.user.GET_res, @@ -230,7 +255,6 @@ function UserRoutes(server) { }, validate: { payload: validate.user.PUT, - options: { allowUnknown: true }, }, response: { schema: validate.user.GET_res, @@ -248,13 +272,8 @@ function UserRoutes(server) { request.payload = Authz.capPermissions(userPerm, request.payload, existingPerm) const hasPermFields = Object.keys(request.payload).some((field) => PERM_FIELDS.has(field)) - if ( - request.payload.inherit_group_permissions === false - || (!existingPerm && hasPermFields) - ) { - request.payload = Authz.preserveUnmanagedPermissions( - userPerm, request.payload, effectivePerm, - ) + if (request.payload.inherit_group_permissions === false || (!existingPerm && hasPermFields)) { + request.payload = Authz.preserveUnmanagedPermissions(userPerm, request.payload, effectivePerm) } const permFields = extractPermFields(request.payload) @@ -272,9 +291,7 @@ function UserRoutes(server) { if (id === user.id) delete request.payload.inherit_group_permissions if (request.payload.inherit_group_permissions !== undefined) { - const after = request.payload.inherit_group_permissions - ? groupPerm - : existingPerm ?? {} + const after = request.payload.inherit_group_permissions ? groupPerm : (existingPerm ?? {}) if (!Authz.canTransitionPermissions(userPerm, effectivePerm, after)) { delete request.payload.inherit_group_permissions } else if (request.payload.inherit_group_permissions === true) { diff --git a/routes/user.test.js b/routes/user.test.js index a987f08..bd27ac6 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -112,7 +112,10 @@ describe('user routes', () => { headers: auth.headers, }) assert.equal(res.statusCode, 200) - assert.deepEqual(res.result.user.map((u) => u.username), [`${userCase.username}2`]) + assert.deepEqual( + res.result.user.map((u) => u.username), + [`${userCase.username}2`], + ) assert.equal(res.result.meta.pagination.filtered, 1) assert.equal(res.result.meta.pagination.limit, 10) assert.equal(res.result.meta.pagination.offset, 0) @@ -140,6 +143,14 @@ describe('user routes', () => { assert.ok(listed.result.user.some((u) => u.id === userId2)) assert.equal(listed.result.meta.pagination.total, listed.result.user.length) + const sorted = await server.inject({ + method: 'GET', + url: `/user?gid=${groupCase.id}&include_subgroups=true&sort_by=group_name&sort_dir=asc`, + headers: auth.headers, + }) + assert.equal(sorted.statusCode, 200) + assert.equal(sorted.result.user.find((u) => u.id === userId2).group_name, moveGroup.name) + const restored = await server.inject({ method: 'PUT', url: `/user/${userId2}`, @@ -153,6 +164,16 @@ describe('user routes', () => { assert.equal((await Authz.permissionRecord(restoredPermission.id)).target_gid, groupCase.id) }) + it(`PUT /user/${userId2} rejects unknown permission controls`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { definitely_not_a_permission: true }, + }) + assert.equal(res.statusCode, 400) + }) + it(`GET /user/${userId2}`, async () => { const res = await server.inject({ method: 'GET', diff --git a/routes/zone.js b/routes/zone.js index 62ca241..0c8ff8a 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -9,7 +9,15 @@ import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' const ZONE_PUT_FIELDS = new Set([ - 'gid', 'description', 'mailaddr', 'serial', 'ttl', 'refresh', 'retry', 'expire', 'minimum', + 'gid', + 'description', + 'mailaddr', + 'serial', + 'ttl', + 'refresh', + 'retry', + 'expire', + 'minimum', 'deleted', ]) @@ -157,7 +165,6 @@ function ZoneRoutes(server) { }, validate: { payload: validate.zone.PUT, - options: { allowUnknown: true }, }, response: { schema: validate.zone.GET_res, @@ -276,10 +283,12 @@ function zoneAuditAction(previous, payload) { } function zoneNameConflict(h) { - return h.response({ - zone: [], - meta: { api: meta.api, msg: `Zone is already taken` }, - }).code(409) + return h + .response({ + zone: [], + meta: { api: meta.api, msg: `Zone is already taken` }, + }) + .code(409) } export default ZoneRoutes diff --git a/routes/zone.test.js b/routes/zone.test.js index 904d189..f646aaa 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -121,16 +121,20 @@ describe('zone routes', () => { }) it('POST /zone admits only one concurrent canonical name', async () => { - const responses = await Promise.all(concurrentDuplicateIds.map((id, index) => server.inject({ - method: 'POST', - url: '/zone', - headers: auth.headers, - payload: { - ...nsCase, - id, - zone: index === 0 ? 'concurrent.example.com' : 'CONCURRENT.EXAMPLE.COM.', - }, - }))) + const responses = await Promise.all( + concurrentDuplicateIds.map((id, index) => + server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { + ...nsCase, + id, + zone: index === 0 ? 'concurrent.example.com' : 'CONCURRENT.EXAMPLE.COM.', + }, + }), + ), + ) for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) assert.deepEqual(responses.map((res) => res.statusCode).sort(), [201, 409]) @@ -175,6 +179,25 @@ describe('zone routes', () => { assert.equal(restored.result.zone[0].gid, groupCase.id) }) + it(`PUT /zone/${nsCase.id} validates serial and unknown fields`, async () => { + const updated = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { serial: 2026082601 }, + }) + assert.equal(updated.statusCode, 200) + assert.equal(updated.result.zone[0].serial, 2026082601) + + const unknown = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { definitely_not_a_zone_field: true }, + }) + assert.equal(unknown.statusCode, 400) + }) + it(`POST /zone (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index 5e2b0df..f810985 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -144,6 +144,15 @@ describe('zone_record routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) + it('GET /log/zone_record requires zid', async () => { + const res = await server.inject({ + method: 'GET', + url: '/log/zone_record', + headers: auth.headers, + }) + assert.equal(res.statusCode, 400) + }) + it('POST /zone_record creates and returns array payload', async () => { const res = await server.inject({ method: 'POST', @@ -174,7 +183,10 @@ describe('zone_record routes', () => { headers: auth.headers, }) assert.equal(zones.statusCode, 200) - assert.deepEqual(zones.result.zone.map((zone) => zone.id), [delegatedZoneId]) + assert.deepEqual( + zones.result.zone.map((zone) => zone.id), + [delegatedZoneId], + ) assert.equal(zones.result.meta.pagination.total, 2) assert.equal(zones.result.meta.pagination.filtered, 1) @@ -184,7 +196,10 @@ describe('zone_record routes', () => { headers: auth.headers, }) assert.equal(records.statusCode, 200) - assert.deepEqual(records.result.zone_record.map((record) => record.id), [delegatedRecordId]) + assert.deepEqual( + records.result.zone_record.map((record) => record.id), + [delegatedRecordId], + ) assert.equal(records.result.meta.pagination.total, 1) assert.equal(records.result.meta.pagination.filtered, 1) }) From 149a54b76b9f5f70c8dffc44a02b31bedf19955c Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:09:05 +0100 Subject: [PATCH 18/48] ci: test proposed validate source --- .github/workflows/ci.yml | 9 +++++++++ docker/Dockerfile | 8 ++++++-- docker/docker-compose.yml | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c75d7..11662c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + NICTOOL_VALIDATE_SPEC: https://github.com/aberoham/validate/archive/c3b37de5fcf42de94c02fa9c97d5a2323fe9cafb.tar.gz + jobs: lint: uses: NicTool/.github/.github/workflows/lint.yml@main @@ -43,6 +46,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-mac: @@ -63,6 +68,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-docker: @@ -100,4 +107,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: sh test/run.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 0fea311..16b7be5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,12 @@ FROM node:22-trixie-slim WORKDIR /app -COPY package*.json . +COPY package*.json ./ +ARG NICTOOL_VALIDATE_SPEC # --omit=dev is safe: tests use node:test (stdlib), devDeps are only eslint/prettier -RUN npm install --omit=dev +RUN npm install --omit=dev \ + && if [ -n "$NICTOOL_VALIDATE_SPEC" ]; then \ + npm install --omit=dev --no-save "$NICTOOL_VALIDATE_SPEC"; \ + fi COPY . . EXPOSE 3000 CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 505c3aa..60e55dc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,6 +24,8 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + NICTOOL_VALIDATE_SPEC: ${NICTOOL_VALIDATE_SPEC:-} ports: - '${API_PORT:-3000}:3000' depends_on: From f22b27a6f115b5e989db713bee0e218944747560 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:19:58 +0100 Subject: [PATCH 19/48] test: await user route cleanup --- routes/user.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/user.test.js b/routes/user.test.js index bd27ac6..883917f 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -28,7 +28,7 @@ const userId2 = 4094 const moveGroup = { id: 4090, parent_gid: groupCase.id, name: 'user-move.route.example.com' } after(async () => { - User.destroy({ id: userId2 }) + await User.destroy({ id: userId2 }) await Group.destroy({ id: moveGroup.id }) await server.stop() }) From a3c00870b53abcc121b9b349ab1abc7ea7ead39a Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:32:30 +0100 Subject: [PATCH 20/48] server: await store disconnection --- routes/index.js | 2 +- routes/lifecycle.test.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 routes/lifecycle.test.js diff --git a/routes/index.js b/routes/index.js index 4554c66..0894662 100644 --- a/routes/index.js +++ b/routes/index.js @@ -146,7 +146,7 @@ async function setup() { } }) - server.events.on('stop', async () => { + server.ext('onPostStop', async () => { await User.disconnect() await Session.disconnect() }) diff --git a/routes/lifecycle.test.js b/routes/lifecycle.test.js new file mode 100644 index 0000000..5ffb152 --- /dev/null +++ b/routes/lifecycle.test.js @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import User from '../lib/user/index.js' +import { init } from './index.js' + +test('server stop waits for store disconnection', async (t) => { + const server = await init() + let enterDisconnect + let releaseDisconnect + const entered = new Promise((resolve) => { + enterDisconnect = resolve + }) + const released = new Promise((resolve) => { + releaseDisconnect = resolve + }) + + t.mock.method(User, 'disconnect', async () => { + enterDisconnect() + await released + }) + + let stopped = false + const stopping = server.stop().then(() => { + stopped = true + }) + await entered + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(stopped, false) + + releaseDisconnect() + await stopping + assert.equal(stopped, true) +}) From 26eaea5c1c362ffadacb4d1c4b133143cb2b7219 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:45:23 +0100 Subject: [PATCH 21/48] test: serialize coverage and force runner exit --- test/backends/json.sh | 6 ++---- test/backends/mysql.sh | 2 +- test/backends/toml.sh | 6 ++---- test/run.sh | 15 +++++++-------- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/test/backends/json.sh b/test/backends/json.sh index d96349e..e3836fd 100755 --- a/test/backends/json.sh +++ b/test/backends/json.sh @@ -17,8 +17,6 @@ test_files() { } run_tests() { - # Run serially: the file store uses shared files; parallel workers cause concurrent-write corruption - for f in $(test_files); do - $NODE --test --test-reporter=spec "$f" || exit 1 - done + # shellcheck disable=SC2046 # word splitting is how the file list is passed + $NODE --test --test-force-exit --test-concurrency=1 "$@" $(test_files) } diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh index 8b994f5..d899e27 100755 --- a/test/backends/mysql.sh +++ b/test/backends/mysql.sh @@ -23,5 +23,5 @@ test_files() { run_tests() { # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --test-concurrency=1 --test-reporter=spec $(test_files) + $NODE --test --test-force-exit --test-concurrency=1 "$@" $(test_files) } diff --git a/test/backends/toml.sh b/test/backends/toml.sh index 345cdf3..6a6571c 100755 --- a/test/backends/toml.sh +++ b/test/backends/toml.sh @@ -17,8 +17,6 @@ test_files() { } run_tests() { - # Run serially: TOML uses shared files; parallel workers cause concurrent-write corruption - for f in $(test_files); do - $NODE --test --test-reporter=spec "$f" || exit 1 - done + # shellcheck disable=SC2046 # word splitting is how the file list is passed + $NODE --test --test-force-exit --test-concurrency=1 "$@" $(test_files) } diff --git a/test/run.sh b/test/run.sh index 5c22c5d..d063dc4 100755 --- a/test/run.sh +++ b/test/run.sh @@ -39,19 +39,18 @@ if [ $# -ge 1 ]; then if [ "$1" = "watch" ]; then $NODE --test --watch elif [ "$1" = "coverage" ]; then - # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --experimental-test-coverage $COVERAGE_EXCLUDE $(test_files) + # shellcheck disable=SC2086 # word splitting passes each exclusion separately + run_tests --experimental-test-coverage $COVERAGE_EXCLUDE elif [ "$1" = "coverage:lcov" ]; then mkdir -p coverage # lcov alone writes only to the file, so a failure exits 1 with an empty log - # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --experimental-test-coverage $COVERAGE_EXCLUDE \ + # shellcheck disable=SC2086 # word splitting passes each exclusion separately + run_tests --experimental-test-coverage $COVERAGE_EXCLUDE \ --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \ - --test-reporter=spec --test-reporter-destination=stdout \ - $(test_files) + --test-reporter=spec --test-reporter-destination=stdout else - $NODE --test --test-reporter=spec "$1" + $NODE --test --test-force-exit --test-reporter=spec "$1" fi else - run_tests + run_tests --test-reporter=spec fi From 0d1d719d493cde387f1119d643542967afe85d6e Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:50:32 +0100 Subject: [PATCH 22/48] zone: limit advisory lock names --- lib/zone/store/mysql.js | 4 +++- lib/zone/test/mysql.js | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index e0a87f8..38526c3 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -8,7 +8,8 @@ const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' } const boolFields = ['deleted'] function zoneLockName(zone) { - return `nictool-zone-${createHash('sha256').update(canonicalZoneName(zone)).digest('hex')}` + const digest = createHash('sha256').update(canonicalZoneName(zone)).digest('hex') + return `ntz:${digest.slice(0, 60)}` } async function assertZoneNameAvailable(db, zone, excludeId) { @@ -274,4 +275,5 @@ class ZoneRepoMySQL extends ZoneBase { } } +export { zoneLockName } export default ZoneRepoMySQL diff --git a/lib/zone/test/mysql.js b/lib/zone/test/mysql.js index c5031ad..8cee95a 100644 --- a/lib/zone/test/mysql.js +++ b/lib/zone/test/mysql.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { describe, it, before, after } from 'node:test' import Zone from '../index.js' +import { zoneLockName } from '../store/mysql.js' import baseCase from './zone.json' with { type: 'json' } @@ -23,6 +24,13 @@ after(async () => { }) describe('zone (mysql)', function () { + it('uses a canonical advisory lock name within the DB limit', () => { + const lockName = zoneLockName('Example.COM.') + assert.equal(Buffer.byteLength(lockName), 64) + assert.equal(lockName, zoneLockName('example.com')) + assert.notEqual(lockName, zoneLockName('other.example.com')) + }) + it('handles null minimum gracefully', async () => { await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) From 73a17f6e922e9df563173982f72319928aafd2da Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:31 +0100 Subject: [PATCH 23/48] authz: keep moves inside the owning tree --- CHANGELOG.md | 1 + lib/authz-plugin.js | 14 +++++++++++++- routes/authz.test.js | 25 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8a9da..9f89d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - session: validate qualified usernames and permission responses - user: add subgroup group-name sorting and reject unknown writes - group/zone: validate permission controls and zone serial updates +- authz: a group change needs the owning tree, delegates edit in place ### [3.0.3] - 2026-07-27 diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index 36a6e5c..d9b9a9b 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -82,7 +82,11 @@ const authzPlugin = { if (permCfg.targetGroupFrom) { const targetGid = resolveId(request, permCfg.targetGroupFrom) - if (targetGid !== undefined) { + // a PUT that keeps the current group is an edit, not a move + const currentGid = targetGid === undefined + ? null + : await Authz.getObjectGroupId(resource, objectId) + if (targetGid !== undefined && Number(targetGid) !== currentGid) { if ( resource === 'user' && action === 'write' @@ -112,6 +116,14 @@ const authzPlugin = { credentials, 'group', 'read', Number(targetGid), ) if (!target.allowed) return respond(target, h) + + // moves stay inside the owning tree, as in v2 + if ( + currentGid !== null + && !await Authz.isInGroupTree(credentials.group.id, currentGid) + ) { + return respond({ allowed: false, code: 404, msg: `Cannot move a delegated ${resource}` }, h) + } } } diff --git a/routes/authz.test.js b/routes/authz.test.js index 2f8fcc2..5ed23c2 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -410,6 +410,31 @@ describe('authz plugin - zone routes', () => { assert.equal(zone.gid, G_ROOT.id) }) + it('403 when a delegate moves the delegated zone into its own tree', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + payload: { gid: G_ROOT.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /delegated/) + + const [zone] = await Zone.get({ id: Z_OUTSIDE.id }) + assert.equal(zone.gid, G_OUTSIDE.id) + }) + + it('200 when a delegate edits the delegated zone without moving it', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + payload: { gid: G_OUTSIDE.id, ttl: 7200 }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.zone[0].gid, G_OUTSIDE.id) + }) + it('rejects unknown zone fields before reaching the store', async () => { const [before] = await Zone.get({ id: Z_INTREE.id }) const res = await server.inject({ From 5b248ec086bdf6de5b0037de40dfc5afdbc6d802 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:31 +0100 Subject: [PATCH 24/48] zone record: sort by the legacy rdata columns --- CHANGELOG.md | 1 + lib/zone_record/store/file.js | 14 +++++++++++++- lib/zone_record/store/mysql.js | 13 ++++++++++++- routes/zone_record.test.js | 8 ++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f89d34..1cf61d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - user: add subgroup group-name sorting and reject unknown writes - group/zone: validate permission controls and zone serial updates - authz: a group change needs the owning tree, delegates edit in place +- zone_record: sort by the legacy rdata columns ### [3.0.3] - 2026-07-27 diff --git a/lib/zone_record/store/file.js b/lib/zone_record/store/file.js index b0a80f9..15e781f 100644 --- a/lib/zone_record/store/file.js +++ b/lib/zone_record/store/file.js @@ -66,7 +66,19 @@ class ZoneRecordRepoFile extends ZoneRecordBase { const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' const hasSort = args.sort_by !== undefined || args.sort_dir !== undefined - const sortBy = ['id', 'owner', 'type', 'ttl'].includes(args.sort_by) ? args.sort_by : 'owner' + const sortable = [ + 'id', + 'owner', + 'type', + 'ttl', + 'address', + 'weight', + 'priority', + 'other', + 'description', + 'location', + ] + const sortBy = sortable.includes(args.sort_by) ? args.sort_by : 'owner' const dir = args.sort_dir === 'desc' ? -1 : 1 const limit = Number.isInteger(args.limit) ? args.limit : undefined const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index 8a59745..08f461a 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -10,7 +10,18 @@ const boolFields = ['deleted'] const keepZeroWeightFor = new Set(['SRV', 'URI']) const keepZeroPriorityFor = new Set(['HTTPS', 'SVCB', 'URI']) -const sortByColumn = { id: 'nt_zone_record_id', owner: 'name', type: 'type_id', ttl: 'ttl' } +const sortByColumn = { + id: 'nt_zone_record_id', + owner: 'name', + type: 'type_id', + ttl: 'ttl', + address: 'address', + weight: 'weight', + priority: 'priority', + other: 'other', + description: 'description', + location: 'location', +} function applyZoneRecordSearch(query, params, search) { const term = typeof search === 'string' ? search.trim() : '' diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index f810985..b7c16e8 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -257,6 +257,14 @@ describe('zone_record routes', () => { assert.equal(res.result.meta.pagination.limit, 2) assert.equal(res.result.meta.pagination.offset, 0) assert.ok(res.result.meta.pagination.total >= 3) + + const byAddress = await server.inject({ + method: 'GET', + url: `/zone_record?zid=${testZoneId}&search=${token}&sort_by=address&sort_dir=desc`, + headers: auth.headers, + }) + assert.equal(byAddress.statusCode, 200) + assert.equal(byAddress.result.zone_record[0].address, '203.0.113.22') }) it(`DELETE /zone_record/${testZoneRecordId} soft-deletes record`, async () => { From 629307c46ef99c39bd0bc7afa7967255f0ae0c46 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:31 +0100 Subject: [PATCH 25/48] session: catch a failed activity touch --- CHANGELOG.md | 1 + lib/authz-plugin.js | 4 +++- routes/session.js | 4 +++- routes/user.test.js | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf61d9..419529b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - group/zone: validate permission controls and zone serial updates - authz: a group change needs the owning tree, delegates edit in place - zone_record: sort by the legacy rdata columns +- session: a failed activity touch no longer rejects unhandled ### [3.0.3] - 2026-07-27 diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index d9b9a9b..00f3dcf 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -30,7 +30,9 @@ const authzPlugin = { } request.auth.credentials = credentials // idle expiry must measure activity; Session.put throttles to once a minute - Session.put({ id: credentials.session.id, last_access: true }) + Session.put({ id: credentials.session.id, last_access: true }).catch((err) => { + console.error(`session ${credentials.session.id} activity: ${err.message}`) + }) } const permCfg = request.route.settings.app?.permission diff --git a/routes/session.js b/routes/session.js index 814bc09..7b86c7d 100644 --- a/routes/session.js +++ b/routes/session.js @@ -25,7 +25,9 @@ function SessionRoutes(server) { handler: async (request, h) => { const { user, group, session } = h.request.auth.credentials - Session.put({ id: session.id, last_access: true }) + Session.put({ id: session.id, last_access: true }).catch((err) => { + console.error(`session ${session.id} activity: ${err.message}`) + }) const perm = await Permission.getEffective(user.id) const groupPerm = await Permission.getGroup({ diff --git a/routes/user.test.js b/routes/user.test.js index 883917f..d058a56 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -70,6 +70,20 @@ describe('user routes', () => { assert.equal(lastAccess, now) }) + it('a failed activity touch does not fail the request', async (t) => { + t.mock.method(Session, 'put', async () => { + throw new Error('store unavailable') + }) + + const res = await server.inject({ + method: 'GET', + url: '/user', + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + await new Promise((resolve) => setImmediate(resolve)) + }) + it('GET /user', async () => { const res = await server.inject({ method: 'GET', From fcf2c85ad23a3c5a628967270755282509630cfa Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:31 +0100 Subject: [PATCH 26/48] test: drop the forced runner exit --- test/backends/json.sh | 2 +- test/backends/mysql.sh | 2 +- test/backends/toml.sh | 2 +- test/run.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/backends/json.sh b/test/backends/json.sh index e3836fd..81ed93c 100755 --- a/test/backends/json.sh +++ b/test/backends/json.sh @@ -18,5 +18,5 @@ test_files() { run_tests() { # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --test-force-exit --test-concurrency=1 "$@" $(test_files) + $NODE --test --test-concurrency=1 "$@" $(test_files) } diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh index d899e27..fe074c6 100755 --- a/test/backends/mysql.sh +++ b/test/backends/mysql.sh @@ -23,5 +23,5 @@ test_files() { run_tests() { # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --test-force-exit --test-concurrency=1 "$@" $(test_files) + $NODE --test --test-concurrency=1 "$@" $(test_files) } diff --git a/test/backends/toml.sh b/test/backends/toml.sh index 6a6571c..e09436e 100755 --- a/test/backends/toml.sh +++ b/test/backends/toml.sh @@ -18,5 +18,5 @@ test_files() { run_tests() { # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --test-force-exit --test-concurrency=1 "$@" $(test_files) + $NODE --test --test-concurrency=1 "$@" $(test_files) } diff --git a/test/run.sh b/test/run.sh index d063dc4..48ae343 100755 --- a/test/run.sh +++ b/test/run.sh @@ -49,7 +49,7 @@ if [ $# -ge 1 ]; then --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \ --test-reporter=spec --test-reporter-destination=stdout else - $NODE --test --test-force-exit --test-reporter=spec "$1" + $NODE --test --test-reporter=spec "$1" fi else run_tests --test-reporter=spec From 9b72283e04c1289e13e7c8ec826e7fc62b651288 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:54:09 +0100 Subject: [PATCH 27/48] ci: test the proposed validate at a4d0785 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11662c4..e3322e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: contents: read env: - NICTOOL_VALIDATE_SPEC: https://github.com/aberoham/validate/archive/c3b37de5fcf42de94c02fa9c97d5a2323fe9cafb.tar.gz + NICTOOL_VALIDATE_SPEC: https://github.com/aberoham/validate/archive/a4d07855a3afe824d7d1d754550cb2d9a75fb145.tar.gz jobs: lint: From d44378e974b52391de7043a85b40d2ca50743058 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:55:35 +0100 Subject: [PATCH 28/48] deps: require validate 1.0.0 validate#29 shipped as 1.0.0, so the temporary install of its PR head in CI, coverage, and the docker build can go. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- .github/workflows/ci.yml | 9 --------- docker/Dockerfile | 6 +----- docker/docker-compose.yml | 2 -- package.json | 2 +- 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3322e6..f8c75d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,6 @@ on: permissions: contents: read -env: - NICTOOL_VALIDATE_SPEC: https://github.com/aberoham/validate/archive/a4d07855a3afe824d7d1d754550cb2d9a75fb145.tar.gz - jobs: lint: uses: NicTool/.github/.github/workflows/lint.yml@main @@ -46,8 +43,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install - - name: Install proposed validate source - run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-mac: @@ -68,8 +63,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install - - name: Install proposed validate source - run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-docker: @@ -107,6 +100,4 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install - - name: Install proposed validate source - run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: sh test/run.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 16b7be5..a75f989 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,12 +1,8 @@ FROM node:22-trixie-slim WORKDIR /app COPY package*.json ./ -ARG NICTOOL_VALIDATE_SPEC # --omit=dev is safe: tests use node:test (stdlib), devDeps are only eslint/prettier -RUN npm install --omit=dev \ - && if [ -n "$NICTOOL_VALIDATE_SPEC" ]; then \ - npm install --omit=dev --no-save "$NICTOOL_VALIDATE_SPEC"; \ - fi +RUN npm install --omit=dev COPY . . EXPOSE 3000 CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 60e55dc..505c3aa 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,8 +24,6 @@ services: build: context: .. dockerfile: docker/Dockerfile - args: - NICTOOL_VALIDATE_SPEC: ${NICTOOL_VALIDATE_SPEC:-} ports: - '${API_PORT:-3000}:3000' depends_on: diff --git a/package.json b/package.json index 0406711..0c8abfd 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@hapi/vision": "^7.0.3", "@msimerson/hapi-openapi": "^18.0.3", "@nictool/dns-resource-record": "^1.8.2", - "@nictool/validate": "^0.9.1", + "@nictool/validate": "^1.0.0", "joi": "^18.2.3", "mysql2": "^3.23.2", "qs": "^6.15.3", From 8d35ec88fbbd7cac8a7b4449ccca549ee481de98 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:41 +0100 Subject: [PATCH 29/48] log: scope record history to readable records A record-only delegation reads the parent zone, so /log/zone_record returned the history of every record in it. Apply the same read scope the record listing uses and let both audit stores take the id list. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/audit/store/base.js | 1 + lib/audit/store/file.js | 1 + lib/audit/store/mysql.js | 8 ++++++- lib/audit/test/index.js | 50 ++++++++++++++++++++++++++++++++++++++++ routes/log.js | 10 +++++++- 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 lib/audit/test/index.js diff --git a/lib/audit/store/base.js b/lib/audit/store/base.js index cf31201..b32c028 100644 --- a/lib/audit/store/base.js +++ b/lib/audit/store/base.js @@ -105,6 +105,7 @@ class AuditBase { throw new Error('listZones() not implemented by this store') } + // args.ids, when present, limits the rows to those zone record ids async listZoneRecords(_args) { throw new Error('listZoneRecords() not implemented by this store') } diff --git a/lib/audit/store/file.js b/lib/audit/store/file.js index a4da949..aa1090c 100644 --- a/lib/audit/store/file.js +++ b/lib/audit/store/file.js @@ -109,6 +109,7 @@ class AuditRepoFile extends AuditBase { rows = rows.filter((row) => row.zid === args.zid) if (Number.isInteger(args.id)) rows = rows.filter((row) => row.id === args.id) + if (Array.isArray(args.ids)) rows = rows.filter((row) => args.ids.includes(row.zrid)) return page(rows, args, { searchKeys: ['owner', 'description', 'type', 'address', 'action', 'user'], diff --git a/lib/audit/store/mysql.js b/lib/audit/store/mysql.js index 801d499..1823334 100644 --- a/lib/audit/store/mysql.js +++ b/lib/audit/store/mysql.js @@ -77,10 +77,16 @@ class AuditRepoMysql extends AuditBase { } async listZoneRecords(args) { - const where = Number.isInteger(args.id) + let where = Number.isInteger(args.id) ? 'rl.nt_zone_id = ? AND rl.nt_zone_record_log_id = ?' : 'rl.nt_zone_id = ?' const params = Number.isInteger(args.id) ? [args.zid, args.id] : [args.zid] + if (Array.isArray(args.ids)) { + where += args.ids.length + ? ` AND rl.nt_zone_record_id IN (${args.ids.map(() => '?').join(', ')})` + : ' AND 0' + params.push(...args.ids) + } return listRows({ select: `SELECT rl.nt_zone_record_log_id AS id, rl.nt_zone_id AS zid, rl.nt_user_id AS uid, rl.nt_zone_record_id AS zrid, rl.timestamp, diff --git a/lib/audit/test/index.js b/lib/audit/test/index.js new file mode 100644 index 0000000..7f571ce --- /dev/null +++ b/lib/audit/test/index.js @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { describe, it, before, after } from 'node:test' + +import Audit from '../index.js' +import Group from '../../group/index.js' +import User from '../../user/index.js' +import Zone from '../../zone/index.js' +import ZoneRecord from '../../zone_record/index.js' + +import groupCase from '../../group/test/group.json' with { type: 'json' } +import userCase from '../../user/test/user.json' with { type: 'json' } + +const zone = { id: 4300, gid: groupCase.id, zone: 'audit.example.com.' } +const seen = { id: 4301, zid: zone.id, owner: 'seen.audit.example.com.', type: 'A', address: '192.0.2.1', ttl: 3600 } +const hidden = { id: 4302, zid: zone.id, owner: 'hidden.audit.example.com.', type: 'A', address: '192.0.2.2', ttl: 3600 } + +before(async () => { + await Group.create(groupCase) + await User.create(userCase) + await Zone.destroy({ id: zone.id }) + await Zone.create({ ...zone, mailaddr: 'hostmaster.audit.example.com.', serial: 1, refresh: 1, retry: 1, expire: 1, minimum: 1, ttl: 1 }) + for (const record of [seen, hidden]) { + await ZoneRecord.destroy({ id: record.id }) + await ZoneRecord.create(record) + await Audit.logZoneRecord(userCase, 'added', record, zone) + } +}) + +after(async () => { + for (const record of [seen, hidden]) await ZoneRecord.destroy({ id: record.id }) + await Zone.destroy({ id: zone.id }) + await Zone.disconnect() +}) + +describe('audit', () => { + it('lists every record log row in a zone', async () => { + const { rows } = await Audit.listZoneRecords({ zid: zone.id, search: 'audit.example.com' }) + assert.deepEqual(rows.map((r) => r.zrid).sort(), [seen.id, hidden.id]) + }) + + it('limits record log rows to the ids a caller may read', async () => { + const { rows } = await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] }) + assert.deepEqual(rows.map((r) => r.zrid), [seen.id]) + }) + + it('returns nothing for an empty read scope', async () => { + const { rows } = await Audit.listZoneRecords({ zid: zone.id, ids: [] }) + assert.deepEqual(rows, []) + }) +}) diff --git a/routes/log.js b/routes/log.js index d29f9dc..447b026 100644 --- a/routes/log.js +++ b/routes/log.js @@ -2,6 +2,7 @@ import validate from '@nictool/validate' import Audit from '../lib/audit/index.js' import Group from '../lib/group/index.js' +import Authz from '../lib/authz/index.js' import { meta } from '../lib/util.js' function LogRoutes(server) { @@ -33,7 +34,14 @@ function LogRoutes(server) { response: { schema: validate.log.GET_res }, tags: ['api'], }, - handler: async (request, h) => logResponse(h, await Audit.listZoneRecords(request.query)), + handler: async (request, h) => { + // a record-only delegation reads the zone but not every record in it + const ids = await Authz.getZoneRecordReadScope( + request.auth.credentials.group.id, request.query.zid, + ) + const args = ids === null ? request.query : { ...request.query, ids } + return logResponse(h, await Audit.listZoneRecords(args)) + }, }, ]) } From f9744d5386f4853c867d5c9e023e478713afa5fe Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:41 +0100 Subject: [PATCH 30/48] authz: default a delegation read to zones The delegation store treats an omitted type as ZONE; the plugin refused the same request as an unknown type before it got there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/authz-plugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index 00f3dcf..41f46cc 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -53,7 +53,7 @@ const authzPlugin = { } if (action === 'readDelegation') { - const type = request.query?.type + const type = request.query?.type ?? 'ZONE' const delegatedResource = TYPE_TO_RESOURCE[type] if (!delegatedResource) { return respond({ allowed: false, code: 404, msg: `Unknown delegation type` }, h) From f53b830a704443701d49980101327b7bb886c0f6 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:41 +0100 Subject: [PATCH 31/48] zone_record: let PUT soft-delete without a 500 The active-only read after a PUT that sets deleted found nothing and the handler threw on it. Read the deleted row back, as the zone route does, and log the deletion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- routes/zone_record.js | 8 +++++--- routes/zone_record.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/routes/zone_record.js b/routes/zone_record.js index 9a1dabc..265c7bd 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -174,11 +174,13 @@ function ZoneRecordRoutes(server) { await ZoneRecord.put({ id, ...request.payload }) - const updated = await ZoneRecord.get({ id }) - const zones = await Zone.get({ id: updated[0].zid }) + let updated = await ZoneRecord.get({ id }) + if (updated.length === 0) updated = await ZoneRecord.get({ id, deleted: true }) + let zones = await Zone.get({ id: updated[0].zid }) + if (zones.length === 0) zones = await Zone.get({ id: updated[0].zid, deleted: true }) await Audit.logZoneRecord( request.auth.credentials.user, - 'modified', + request.payload.deleted === true ? 'deleted' : 'modified', updated[0], zones[0], zrs[0], diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index b7c16e8..9fc79f0 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -267,6 +267,33 @@ describe('zone_record routes', () => { assert.equal(byAddress.result.zone_record[0].address, '203.0.113.22') }) + it(`PUT /zone_record/${testZoneRecordId} can soft-delete and recover`, async () => { + const gone = await server.inject({ + method: 'PUT', + url: `/zone_record/${testZoneRecordId}`, + headers: auth.headers, + payload: { deleted: true }, + }) + assert.equal(gone.statusCode, 200) + assert.equal(gone.result.zone_record[0].deleted, true) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${testZoneId}&search=www.route-zr-delete`, + headers: auth.headers, + }) + assert.equal(log.result.log[0].action, 'deleted') + + const back = await server.inject({ + method: 'PUT', + url: `/zone_record/${testZoneRecordId}`, + headers: auth.headers, + payload: { deleted: false }, + }) + assert.equal(back.statusCode, 404, 'a deleted record is not editable through PUT') + await ZoneRecord.delete({ id: testZoneRecordId, deleted: 0 }) + }) + it(`DELETE /zone_record/${testZoneRecordId} soft-deletes record`, async () => { const res = await server.inject({ method: 'DELETE', From cd36b58557ece31122e0e1cc5332b8b12ead4fed Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:41 +0100 Subject: [PATCH 32/48] delegation: serialize duplicate check and insert nt_delegate has no unique key, so identical concurrent POSTs could both pass the check. Run it with the insert in a transaction under a lock named for the delegation; the audit row is written after commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/delegation/store/mysql.js | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/delegation/store/mysql.js b/lib/delegation/store/mysql.js index 5815d86..25c519d 100644 --- a/lib/delegation/store/mysql.js +++ b/lib/delegation/store/mysql.js @@ -13,13 +13,6 @@ class DelegationRepoMysql extends DelegationBase { async create(args) { const { gid, oid, type } = args - const existing = await Mysql.execute( - `SELECT nt_group_id FROM nt_delegate - WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, - [gid, oid, type], - ) - if (existing.length > 0) return { duplicate: true } - const row = { nt_group_id: gid, nt_object_id: oid, @@ -32,11 +25,28 @@ class DelegationRepoMysql extends DelegationBase { row[f] = args[f] === true ? 1 : 0 } - await Mysql.execute(...Mysql.insert('nt_delegate', row)) + // nt_delegate has no unique key, so the check and insert must not + // interleave; the lock goes with the connection when the transaction ends + const result = await Mysql.transaction(async (tx) => { + const [lock] = await tx.execute('SELECT GET_LOCK(?, 10) AS acquired', [ + `ntd:${type}:${oid}:${gid}`, + ]) + if (lock.acquired !== 1) throw new Error(`Could not lock delegation ${type} ${oid} for group ${gid}`) + + const existing = await tx.execute( + `SELECT nt_group_id FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length > 0) return { duplicate: true } + + await tx.execute(...tx.insert('nt_delegate', row)) + return { created: true } + }) - await this.writeLog(row, 'delegated') + if (result.created) await this.writeLog(row, 'delegated') - return { created: true } + return result } async getDelegated(gid, objType) { From 13826e2a1dfbe55a762e0037f23285d3fdc4960c Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:41 +0100 Subject: [PATCH 33/48] permission: file store honours deleted rows, gids Inline permission rows were returned whatever their deleted flag, so a deleted explicit permission kept authorizing and never fell back to the group. Return a row only in the state asked for, replace a deleted row on create as the mysql store does, and let a lookup by id reach group rows after user and standalone ones. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/permission/store/file.js | 78 ++++++++++++++++++------------------ lib/permission/test/index.js | 20 ++++++++- routes/authz.test.js | 24 +++++++++++ 3 files changed, 83 insertions(+), 39 deletions(-) diff --git a/lib/permission/store/file.js b/lib/permission/store/file.js index b2ef63a..97807e2 100644 --- a/lib/permission/store/file.js +++ b/lib/permission/store/file.js @@ -18,7 +18,7 @@ import PermissionBase from './base.js' * * get({ uid }) → inline permissions of that user * get({ gid }) → inline permissions of that group (uid absent) - * get({ id }) → search user → group → standalone by permissions.id + * get({ id }) → search user → standalone → group by permissions.id * getGroup({ uid }) → permissions of the group the user belongs to */ class PermissionRepoFile extends PermissionBase { @@ -99,12 +99,15 @@ class PermissionRepoFile extends PermissionBase { const idx = users.findIndex((u) => u.id === uid) if (idx !== -1) { - // Store inline in user.toml using the actual permission data from args - if (!users[idx].permissions) { + // Store inline in user.toml using the actual permission data from args; + // a soft-deleted row is replaced, as the mysql store does + const current = users[idx].permissions + if (!current || current.deleted) { const perm = deepMerge(permissionDefaults(uid, gid ?? users[idx].gid), args) - perm.id = args.id ?? await this._nextId() + perm.id = current?.id ?? args.id ?? await this._nextId() perm.user.id = uid perm.group.id = gid ?? users[idx].gid + perm.deleted = false users[idx].permissions = perm } await this._saveUsers(users) @@ -120,10 +123,12 @@ class PermissionRepoFile extends PermissionBase { if (idx !== -1) { // Store inline in group.toml - if (!groups[idx].permissions) { + const current = groups[idx].permissions + if (!current || current.deleted) { const perm = deepMerge(permissionDefaults(null, gid), args) - perm.id = args.id ?? await this._nextId() + perm.id = current?.id ?? args.id ?? await this._nextId() perm.group.id = gid + perm.deleted = false groups[idx].permissions = perm } await this._saveGroups(groups) @@ -138,11 +143,15 @@ class PermissionRepoFile extends PermissionBase { if (permId === undefined) return undefined const perms = await this._loadStandalone() - if (!perms.find((p) => p.id === permId)) { - const perm = { ...args, id: permId } + const pidx = perms.findIndex((p) => p.id === permId) + if (pidx === -1 || perms[pidx].deleted) { + const perm = deepMerge(permissionDefaults(uid ?? null, gid ?? null), args) + perm.id = permId + perm.deleted = false if (uid !== undefined) perm.uid = uid if (gid !== undefined) perm.gid = gid - perms.push(perm) + if (pidx === -1) perms.push(perm) + else perms[pidx] = perm await this._saveStandalone(perms) } return permId @@ -152,48 +161,40 @@ class PermissionRepoFile extends PermissionBase { args = JSON.parse(JSON.stringify(args)) const deletedArg = args.deleted ?? false + // an inline row is only returned in the deleted state asked for, so a + // deleted explicit permission stops authorizing and getEffective falls + // back to the group + const inline = (perm) => { + if (!perm) return undefined + if (Boolean(perm.deleted) !== Boolean(deletedArg)) return undefined + return this._postProcess(perm, deletedArg) + } + if (args.uid !== undefined) { const users = await this._loadUsers() - const user = users.find((u) => u.id === args.uid) - if (!user?.permissions) return undefined - const perm = this._postProcess(user.permissions, deletedArg) - if (deletedArg === true && perm.deleted !== true) return undefined - return perm + return inline(users.find((u) => u.id === args.uid)?.permissions) } if (args.gid !== undefined) { // group-level lookup: no uid qualifier const groups = await this._loadGroups() - const group = groups.find((g) => g.id === args.gid) - if (!group?.permissions) return undefined - const perm = this._postProcess(group.permissions, deletedArg) - if (deletedArg === true && perm.deleted !== true) return undefined - return perm + return inline(groups.find((g) => g.id === args.gid)?.permissions) } if (args.id !== undefined) { - // Search user.toml by permissions.id. - // NOTE: group.toml is intentionally NOT searched here — group permissions are - // accessed via { gid }. Searching groups would cause false positives after a - // user permission is destroyed, because user and group share the same numeric - // id space (both user 4096 and group 4096 set permissions.id = 4096). + // user rows first, group rows last: explicit ids can collide with + // generated ones (fixtures give user 4096 and group 4096 the same id), + // and the user's own row is the one /permission/{id} means const users = await this._loadUsers() - const user = users.find((u) => u.permissions?.id === args.id) - if (user?.permissions) { - const perm = this._postProcess(user.permissions, deletedArg) - if (deletedArg === true && perm.deleted !== true) return undefined - return perm - } + const fromUser = inline(users.find((u) => u.permissions?.id === args.id)?.permissions) + if (fromUser) return fromUser - // Check standalone permission.toml const perms = await this._loadStandalone() - const found = perms.find((p) => p.id === args.id) - if (found) { - const isDeleted = Boolean(found.deleted) - const wantDeleted = Boolean(deletedArg) - if (isDeleted !== wantDeleted) return undefined - return this._postProcess(found, deletedArg) - } + const fromStandalone = inline(perms.find((p) => p.id === args.id)) + if (fromStandalone) return fromStandalone + + const groups = await this._loadGroups() + return inline(groups.find((g) => g.permissions?.id === args.id)?.permissions) } return undefined @@ -209,6 +210,7 @@ class PermissionRepoFile extends PermissionBase { if (!group?.permissions) return undefined const deletedArg = args.deleted ?? false + if (Boolean(group.permissions.deleted) !== Boolean(deletedArg)) return undefined return this._postProcess(group.permissions, deletedArg) } diff --git a/lib/permission/test/index.js b/lib/permission/test/index.js index 27f9e67..a7c8555 100644 --- a/lib/permission/test/index.js +++ b/lib/permission/test/index.js @@ -73,6 +73,24 @@ describe('permission', function () { })) }) + it('finds a group permission by its id', async () => { + const gid = 4300 + await Group.destroy({ id: gid }) + await Group.create({ id: gid, parent_gid: groupTestCase.id, name: 'perm-by-id' }) + const gp = await Permission.get({ gid }) + assert.equal((await Permission.get({ id: gp.id }))?.group.id, gid) + await Group.destroy({ id: gid }) + }) + + it('stops applying a deleted explicit permission', async () => { + await Permission.delete({ id: permTestCase.id }) + assert.equal(await Permission.get({ uid: userTestCase.id }), undefined) + const effective = await Permission.getEffective(userTestCase.id) + assert.equal(effective.group.id, groupTestCase.id) + assert.equal(effective.name, `Group ${groupTestCase.name} perms`) + await Permission.delete({ id: permTestCase.id, deleted: 0 }) + }) + it('reactivates a soft-deleted permission instead of duplicating it', async () => { await Permission.delete({ id: permTestCase.id }) assert.equal(await Permission.create(permTestCase), permTestCase.id) @@ -90,7 +108,7 @@ describe('permission', function () { it('destroys a permission', async () => { assert.ok(await Permission.destroy({ id: permTestCase.id })) - const p = await Permission.get({ id: permTestCase.id }) + const p = await Permission.get({ uid: userTestCase.id }) assert.equal(p, undefined) }) }) diff --git a/routes/authz.test.js b/routes/authz.test.js index 5ed23c2..9854eab 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -789,6 +789,30 @@ describe('authz plugin - delegation routes', () => { assert.equal(res.result.delegation[0].nt_group_id, G_CHILD.id) }) + it('GET without a type reads zone delegations, as the store defaults', async () => { + const res = await server.inject({ + method: 'GET', + url: `/delegation?gid=${G_CHILD.id}&oid=${Z_INTREE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.delegation.length, 1) + assert.equal(res.result.delegation[0].nt_group_id, G_CHILD.id) + }) + + it('creates one delegation when identical requests race', { + skip: (process.env.NICTOOL_DATA_STORE ?? 'mysql') !== 'mysql', + }, async () => { + const args = { gid: G_CHILD.id, oid: ZR_INTREE_OTHER.id, type: 'ZONERECORD' } + await Delegation.delete(args) + const results = await Promise.all([1, 2, 3].map(() => Delegation.create(args))) + assert.equal(results.filter((r) => r.created).length, 1) + assert.equal(results.filter((r) => r.duplicate).length, 2) + const rows = await Delegation.get(args) + assert.equal(rows.length, 1) + await Delegation.delete(args) + }) + it('cannot delegate an object back to your own group', async () => { const res = await server.inject({ method: 'POST', From 4c6c86ec720c330ce01af05cccf1c5d14f962692 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:20:19 +0100 Subject: [PATCH 34/48] zone: assign nameservers through the api The v2 GUI posts a zone's nameserver selection on create and edit, and v3 only exposed a read-only /zone/{id}/ns. Zone create and put now take `nameservers` (nameserver ids) and store them through the zone store, inside the zone's transaction on mysql and under the write lock on the file store; a single-zone read carries the assignment back. A caller may only assign nameservers it can use: owned by a group in its tree, or granted through usable_ns. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/zone/store/base.js | 13 +++++++ lib/zone/store/file.js | 60 ++++++++++++++++++++++++++------- lib/zone/store/mysql.js | 54 +++++++++++++++++++++++++++-- lib/zone/test/index.js | 38 +++++++++++++++++++++ lib/zone/test/mysql.js | 9 +++++ routes/zone.js | 46 ++++++++++++++++++++++++- routes/zone.test.js | 75 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 278 insertions(+), 17 deletions(-) diff --git a/lib/zone/store/base.js b/lib/zone/store/base.js index 749cbf0..75265eb 100644 --- a/lib/zone/store/base.js +++ b/lib/zone/store/base.js @@ -9,6 +9,11 @@ * delete(args) → boolean * destroy(args) → boolean * nameserversFor(zid) → object[] ({zone, name, ttl} for the zone's NS) + * nameserverIds(zid) → number[] (nameserver ids assigned to the zone) + * setNameservers(zid, ids) → boolean (replace the zone's assignments) + * + * create() and put() accept `nameservers` (an array of nameserver ids) and + * store it through setNameservers(); it is never a zone column. */ class ZoneBase { constructor(args = {}) { @@ -23,6 +28,14 @@ class ZoneBase { throw new Error('nameserversFor() not implemented by this repo') } + async nameserverIds(_zid) { + throw new Error('nameserverIds() not implemented by this repo') + } + + async setNameservers(_zid, _ids) { + throw new Error('setNameservers() not implemented by this repo') + } + async count(_args) { throw new Error('count() not implemented by this repo') } diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index 84bf17e..f6d9f10 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -30,6 +30,19 @@ class ZoneRepoFile extends ZoneBase { constructor(args = {}) { super(args) this.file = new FileStore('zone') + this.nsFile = new FileStore('zone_nameserver') + } + + async _loadNs() { + return this.nsFile.load('zone_nameserver') + } + + // written after the zone file, under the same lock; a crash between the + // two leaves them out of step + async _replaceNs(zid, ids) { + const rows = (await this._loadNs()).filter((m) => m.zid !== zid) + for (const nid of new Set(ids.map(Number))) rows.push({ zid, nid }) + await this.nsFile.save('zone_nameserver', rows) } async _load() { @@ -57,13 +70,15 @@ class ZoneRepoFile extends ZoneBase { } async create(args) { + const { nameservers, ...zone } = args return withZoneWriteLock(async () => { const zones = await this._load() - if (args.id && zones.some((zone) => zone.id === args.id)) return args.id - if (args.deleted !== true) assertZoneNameAvailable(zones, args.zone) - zones.push(JSON.parse(JSON.stringify(args))) + if (zone.id && zones.some((z) => z.id === zone.id)) return zone.id + if (zone.deleted !== true) assertZoneNameAvailable(zones, zone.zone) + zones.push(JSON.parse(JSON.stringify(zone))) await this._save(zones) - return args.id + if (Array.isArray(nameservers)) await this._replaceNs(zone.id, nameservers) + return zone.id }) } @@ -164,14 +179,16 @@ class ZoneRepoFile extends ZoneBase { async put(args) { if (!args.id) return false + const { nameservers, ...fields } = args return withZoneWriteLock(async () => { const zones = await this._load() - const idx = zones.findIndex((z) => z.id === args.id) + const idx = zones.findIndex((z) => z.id === fields.id) if (idx === -1) return false - if (args.deleted === false) assertZoneNameAvailable(zones, zones[idx].zone, args.id) + if (fields.deleted === false) assertZoneNameAvailable(zones, zones[idx].zone, fields.id) - zones[idx] = { ...zones[idx], ...args } + zones[idx] = { ...zones[idx], ...fields } await this._save(zones) + if (Array.isArray(nameservers)) await this._replaceNs(fields.id, nameservers) return true }) } @@ -187,12 +204,29 @@ class ZoneRepoFile extends ZoneBase { } async destroy(args) { - const zones = await this._load() - const before = zones.length - const filtered = zones.filter((z) => z.id !== args.id) - if (filtered.length === before) return false - await this._save(filtered) - return true + return withZoneWriteLock(async () => { + const zones = await this._load() + const before = zones.length + const filtered = zones.filter((z) => z.id !== args.id) + if (filtered.length === before) return false + await this._save(filtered) + await this._replaceNs(args.id, []) + return true + }) + } + + async nameserverIds(zid) { + return (await this._loadNs()) + .filter((m) => m.zid === zid) + .map((m) => m.nid) + .sort((a, b) => a - b) + } + + async setNameservers(zid, ids) { + return withZoneWriteLock(async () => { + await this._replaceNs(zid, ids) + return true + }) } // the zone<->nameserver mapping is its own document; a deployment that diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index 38526c3..7bb4341 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -88,9 +88,16 @@ class ZoneRepoMySQL extends ZoneBase { if (g.length === 1) return g[0].id } - return withZoneNameLock(args.zone, async (tx) => { - if (args.deleted !== true) await assertZoneNameAvailable(tx, args.zone) - return tx.execute(...tx.insert(`nt_zone`, mapToDbColumn(args, zoneDbMap))) + const { nameservers, ...zone } = args + + return withZoneNameLock(zone.zone, async (tx) => { + if (zone.deleted !== true) await assertZoneNameAvailable(tx, zone.zone) + const insertId = await tx.execute(...tx.insert(`nt_zone`, mapToDbColumn(zone, zoneDbMap))) + // an explicit-id insert can report insertId 0 (see the group store), + // and id 0 in the payload means auto-increment + const id = zone.id || insertId + if (Array.isArray(nameservers)) await this.setNameservers(id, nameservers, tx) + return id }) } @@ -223,6 +230,15 @@ class ZoneRepoMySQL extends ZoneBase { const id = args.id delete args.id + const nameservers = args.nameservers + delete args.nameservers + + if (Object.keys(args).length === 0) { + if (!Array.isArray(nameservers)) return false + await Mysql.transaction((tx) => this.setNameservers(id, nameservers, tx)) + return true + } + if (args.deleted === false) { const rows = await Mysql.execute( 'SELECT zone FROM nt_zone WHERE nt_zone_id = ? LIMIT 1', @@ -234,10 +250,21 @@ class ZoneRepoMySQL extends ZoneBase { const r = await tx.execute( ...tx.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), ) + if (Array.isArray(nameservers)) await this.setNameservers(id, nameservers, tx) return r.changedRows === 1 }) } + if (Array.isArray(nameservers)) { + return Mysql.transaction(async (tx) => { + const r = await tx.execute( + ...tx.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), + ) + await this.setNameservers(id, nameservers, tx) + return r.affectedRows === 1 + }) + } + const r = await Mysql.execute( ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), ) @@ -254,10 +281,31 @@ class ZoneRepoMySQL extends ZoneBase { } async destroy(args) { + // no foreign key cleans the assignments up + await Mysql.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [args.id]) const r = await Mysql.execute(...Mysql.delete(`nt_zone`, { nt_zone_id: args.id })) return r.affectedRows === 1 } + async nameserverIds(zid) { + const rows = await Mysql.execute( + 'SELECT nt_nameserver_id AS id FROM nt_zone_nameserver WHERE nt_zone_id = ? ORDER BY nt_nameserver_id', + [zid], + ) + return rows.map((r) => r.id) + } + + async setNameservers(zid, ids, db = Mysql) { + await db.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [zid]) + for (const nid of new Set(ids.map(Number))) { + await db.execute( + 'INSERT INTO nt_zone_nameserver (nt_zone_id, nt_nameserver_id) VALUES (?, ?)', + [zid, nid], + ) + } + return true + } + disconnect() { return this.mysql?.disconnect() } diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index d15548b..5a3c3fa 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -35,6 +35,44 @@ describe('zone', function () { assert.ok(await Zone.put({ id: testCase.id, mailaddr: testCase.mailaddr })) }) + describe('nameservers', () => { + it('starts with no assignment', async () => { + assert.deepEqual(await Zone.nameserverIds(testCase.id), []) + }) + + it('assigns nameservers alongside other fields', async () => { + assert.ok(await Zone.put({ id: testCase.id, description: 'ns', nameservers: [4096, 4095, 4096] })) + assert.deepEqual(await Zone.nameserverIds(testCase.id), [4095, 4096]) + const z = await Zone.get({ id: testCase.id }) + assert.equal(z[0].description, 'ns') + assert.equal(z[0].nameservers, undefined) + }) + + it('replaces the assignment on its own', async () => { + assert.ok(await Zone.put({ id: testCase.id, nameservers: [4096] })) + assert.deepEqual(await Zone.nameserverIds(testCase.id), [4096]) + }) + + it('leaves the assignment alone when absent', async () => { + await Zone.put({ id: testCase.id, description: testCase.description }) + assert.deepEqual(await Zone.nameserverIds(testCase.id), [4096]) + }) + + it('clears the assignment with an empty list', async () => { + assert.ok(await Zone.put({ id: testCase.id, nameservers: [] })) + assert.deepEqual(await Zone.nameserverIds(testCase.id), []) + }) + + it('stores the assignment on create and drops it on destroy', async () => { + const id = testCase.id + 1 + await Zone.destroy({ id }) + await Zone.create({ ...testCase, id, zone: 'ns.example.com', nameservers: [4095] }) + assert.deepEqual(await Zone.nameserverIds(id), [4095]) + assert.ok(await Zone.destroy({ id })) + assert.deepEqual(await Zone.nameserverIds(id), []) + }) + }) + describe('deletes a zone', async () => { it('can delete a zone', async () => { assert.ok(await Zone.delete({ id: testCase.id })) diff --git a/lib/zone/test/mysql.js b/lib/zone/test/mysql.js index 8cee95a..c1fac45 100644 --- a/lib/zone/test/mysql.js +++ b/lib/zone/test/mysql.js @@ -31,6 +31,15 @@ describe('zone (mysql)', function () { assert.notEqual(lockName, zoneLockName('other.example.com')) }) + it('assigns nameservers to an auto-increment id', async () => { + const id = await Zone.create({ + ...testCase, id: 0, zone: 'auto-ns.example.com', nameservers: [4095], + }) + assert.ok(id > 0) + assert.deepEqual(await Zone.nameserverIds(id), [4095]) + assert.ok(await Zone.destroy({ id })) + }) + it('handles null minimum gracefully', async () => { await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) diff --git a/routes/zone.js b/routes/zone.js index 0c8ff8a..1a9b63d 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -4,11 +4,13 @@ import Zone from '../lib/zone/index.js' import { ZoneNameConflictError } from '../lib/zone/store/base.js' import Group from '../lib/group/index.js' import Authz from '../lib/authz/index.js' +import Permission from '../lib/permission/index.js' import Audit from '../lib/audit/index.js' import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' const ZONE_PUT_FIELDS = new Set([ + 'nameservers', 'gid', 'description', 'mailaddr', @@ -21,6 +23,40 @@ const ZONE_PUT_FIELDS = new Set([ 'deleted', ]) +// a zone may only be assigned nameservers the caller can use: those owned +// by a group in the caller's tree, or granted through usable_ns (the v2 rule) +async function unusableNameservers(request) { + const requested = request.payload.nameservers + if (!Array.isArray(requested)) return [] + const ids = [...new Set(requested.map(Number))] + request.payload.nameservers = ids + + const { user, group } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + const usable = new Set((userPerm?.nameserver?.usable ?? []).map(Number)) + + const unusable = [] + for (const nid of ids) { + if (usable.has(nid)) continue + const nsGid = await Authz.getObjectGroupId('nameserver', nid) + if (nsGid !== null && (await Authz.isInGroupTree(group.id, nsGid))) continue + unusable.push(nid) + } + return unusable +} + +function nameserversNotUsable(h, ids) { + return h + .response({ meta: { api: meta.api, msg: `nameserver(s) not usable: ${ids.join(', ')}` } }) + .code(403) +} + +// single-zone responses carry the assignment; lists stay one query +async function withNameservers(zones) { + for (const zone of zones) zone.nameservers = await Zone.nameserverIds(zone.id) + return zones +} + function ZoneRoutes(server) { server.route([ { @@ -97,6 +133,7 @@ function ZoneRoutes(server) { Zone.count(countArgs), Zone.count(totalArgs), ]) + if (getArgs.id) await withNameservers(zones) return h .response({ @@ -129,6 +166,9 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { + const unusable = await unusableNameservers(request) + if (unusable.length) return nameserversNotUsable(h, unusable) + let id try { id = await Zone.create(request.payload) @@ -137,7 +177,7 @@ function ZoneRoutes(server) { throw err } - const zones = await Zone.get({ id }) + const zones = await withNameservers(await Zone.get({ id })) await Audit.logZone(request.auth.credentials.user, 'added', zones[0]) return h @@ -180,6 +220,9 @@ function ZoneRoutes(server) { return h.response({ meta: { api: meta.api, msg: `I couldn't find that zone` } }).code(404) } + const unusable = await unusableNameservers(request) + if (unusable.length) return nameserversNotUsable(h, unusable) + const payload = Object.fromEntries( Object.entries(request.payload).filter(([key]) => ZONE_PUT_FIELDS.has(key)), ) @@ -192,6 +235,7 @@ function ZoneRoutes(server) { let updated = await Zone.get({ id }) if (updated.length === 0) updated = await Zone.get({ id, deleted: true }) + await withNameservers(updated) await Audit.logZone( request.auth.credentials.user, zoneAuditAction(zones[0], payload), diff --git a/routes/zone.test.js b/routes/zone.test.js index f646aaa..e4b35e5 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -5,11 +5,13 @@ import { init } from './index.js' import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' +import Nameserver from '../lib/nameserver/index.js' import groupCase from './test/group.json' with { type: 'json' } import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/zone.json' with { type: 'json' } +import nameserverCase from './test/nameserver.json' with { type: 'json' } let server let case2Id = 4094 @@ -20,6 +22,11 @@ const recoveryDuplicateId = 4086 const subGroup = { id: 4090, parent_gid: groupCase.id, name: 'sub.route.example.com' } const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.example.com.' } +// one nameserver in the caller's group, one it cannot use +const ownNs = { ...nameserverCase, id: 4093, gid: groupCase.id, name: 'own.ns.example.com.' } +const otherNs = { ...nameserverCase, id: 4092, gid: 1, name: 'other.ns.example.com.' } +const nsZoneId = 4089 +const refusedZoneId = 4085 before(async () => { await Zone.destroy({ id: nsCase.id }) @@ -42,10 +49,19 @@ before(async () => { await Zone.create(nsCase) await Group.create(subGroup) await Zone.create(subZone) + await Zone.destroy({ id: nsZoneId }) + await Zone.destroy({ id: refusedZoneId }) + await Nameserver.destroy({ id: ownNs.id }) + await Nameserver.destroy({ id: otherNs.id }) + await Nameserver.create(ownNs) + await Nameserver.create(otherNs) server = await init() }) after(async () => { + await Zone.destroy({ id: nsZoneId }) + await Nameserver.destroy({ id: ownNs.id }) + await Nameserver.destroy({ id: otherNs.id }) await Zone.destroy({ id: duplicateId }) await Zone.destroy({ id: duplicateCaseId }) await Zone.destroy({ id: recoveryDuplicateId }) @@ -198,6 +214,65 @@ describe('zone routes', () => { assert.equal(unknown.statusCode, 400) }) + it(`PUT /zone/${nsCase.id} assigns a usable nameserver`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { nameservers: [ownNs.id, ownNs.id] }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.zone[0].nameservers, [ownNs.id]) + + const get = await server.inject({ method: 'GET', url: `/zone/${nsCase.id}`, headers: auth.headers }) + assert.deepEqual(get.result.zone[0].nameservers, [ownNs.id]) + }) + + it(`PUT /zone/${nsCase.id} refuses a nameserver the caller cannot use`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { nameservers: [ownNs.id, otherNs.id] }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.meta.msg, /not usable: 4092/) + assert.deepEqual(await Zone.nameserverIds(nsCase.id), [ownNs.id]) + }) + + it(`PUT /zone/${nsCase.id} clears the assignment`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { nameservers: [] }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.zone[0].nameservers, []) + }) + + it(`POST /zone (${nsZoneId}) with nameservers`, async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { ...nsCase, id: nsZoneId, zone: 'ns.route.example.com.', nameservers: [ownNs.id] }, + }) + assert.equal(res.statusCode, 201) + assert.deepEqual(res.result.zone[0].nameservers, [ownNs.id]) + }) + + it('POST /zone rejects a nameserver the caller cannot use', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { ...nsCase, id: refusedZoneId, zone: 'ns2.route.example.com.', nameservers: [otherNs.id] }, + }) + assert.equal(res.statusCode, 403) + assert.equal((await Zone.get({ id: refusedZoneId })).length, 0) + }) + it(`POST /zone (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique From 7a64bad17c7e0e6961e9fe25da725df9f50ed8ab Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:07:25 +0100 Subject: [PATCH 35/48] ci: test against validate main until #31 ships The zone nameservers routes need the schema from validate#31, merged as 0a09c61 but not released. Drop this once the release lands and the dependency bumps. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- .github/workflows/ci.yml | 9 +++++++++ .github/workflows/coverage.yml | 34 ++++++++++++++++++++++++++++++---- docker/Dockerfile | 6 +++++- docker/docker-compose.yml | 2 ++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c75d7..725fdd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + NICTOOL_VALIDATE_SPEC: https://github.com/NicTool/validate/archive/0a09c61bd174cd75e5b5ec62c83e947c432be98d.tar.gz + jobs: lint: uses: NicTool/.github/.github/workflows/lint.yml@main @@ -43,6 +46,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-mac: @@ -63,6 +68,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-docker: @@ -100,4 +107,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: sh test/run.sh diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 695f13c..43670dc 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,11 +6,37 @@ on: push: branches: [main] +env: + NICTOOL_VALIDATE_SPEC: https://github.com/NicTool/validate/archive/0a09c61bd174cd75e5b5ec62c83e947c432be98d.tar.gz + jobs: coverage: - uses: NicTool/.github/.github/workflows/coverage.yml@main - secrets: inherit + runs-on: ubuntu-latest permissions: contents: read - with: - mysql: true + steps: + - name: Start MySQL + run: sudo /etc/init.d/mysql start + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} + - name: Initialize MySQL + run: sh sql/init-mysql.sh + - run: npm run test:coverage:lcov + env: + NODE_ENV: cov + - name: codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + disable_search: true + fail_ci_if_error: true + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.github_token }} diff --git a/docker/Dockerfile b/docker/Dockerfile index a75f989..16b7be5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,12 @@ FROM node:22-trixie-slim WORKDIR /app COPY package*.json ./ +ARG NICTOOL_VALIDATE_SPEC # --omit=dev is safe: tests use node:test (stdlib), devDeps are only eslint/prettier -RUN npm install --omit=dev +RUN npm install --omit=dev \ + && if [ -n "$NICTOOL_VALIDATE_SPEC" ]; then \ + npm install --omit=dev --no-save "$NICTOOL_VALIDATE_SPEC"; \ + fi COPY . . EXPOSE 3000 CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 505c3aa..60e55dc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,6 +24,8 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + NICTOOL_VALIDATE_SPEC: ${NICTOOL_VALIDATE_SPEC:-} ports: - '${API_PORT:-3000}:3000' depends_on: From fdf17bc75015a529d795636e133945f22431628a Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:21:32 +0100 Subject: [PATCH 36/48] file stores: serialize writes to a file The audit and delegation stores loaded, appended, and saved without a lock, so overlapping writers could hand out the same id and the later save dropped the earlier row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/audit/store/file.js | 46 +++++++-------- lib/audit/test/index.js | 44 ++++++++++++-- lib/delegation/store/file.js | 108 +++++++++++++++++------------------ lib/store/file.js | 27 +++++++++ 4 files changed, 142 insertions(+), 83 deletions(-) diff --git a/lib/audit/store/file.js b/lib/audit/store/file.js index aa1090c..ba6bdca 100644 --- a/lib/audit/store/file.js +++ b/lib/audit/store/file.js @@ -12,35 +12,35 @@ class AuditRepoFile extends AuditBase { } async insertZoneLog(detail) { - const rows = await this.zoneLog.load('zone_log') - const row = { id: nextId(rows), ...detail } - rows.push(row) - await this.zoneLog.save('zone_log', rows) - return row.id + return this.zoneLog.update('zone_log', (rows) => { + const row = { id: nextId(rows), ...detail } + rows.push(row) + return row.id + }) } async insertZoneRecordLog(detail) { - const rows = await this.recordLog.load('record_log') - const row = { id: nextId(rows), ...detail } - rows.push(row) - await this.recordLog.save('record_log', rows) - return row.id + return this.recordLog.update('record_log', (rows) => { + const row = { id: nextId(rows), ...detail } + rows.push(row) + return row.id + }) } async insertGlobalLog(entry) { - const rows = await this.globalLog.load('global_log') - rows.push({ - id: nextId(rows), - uid: entry.uid, - timestamp: entry.timestamp, - action: entry.action, - object: entry.object, - object_id: entry.objectId, - log_entry_id: entry.logId, - title: entry.title, - description: entry.description, - }) - await this.globalLog.save('global_log', rows) + await this.globalLog.update('global_log', (rows) => + rows.push({ + id: nextId(rows), + uid: entry.uid, + timestamp: entry.timestamp, + action: entry.action, + object: entry.object, + object_id: entry.objectId, + log_entry_id: entry.logId, + title: entry.title, + description: entry.description, + }), + ) } async listGlobal(args) { diff --git a/lib/audit/test/index.js b/lib/audit/test/index.js index 7f571ce..0450095 100644 --- a/lib/audit/test/index.js +++ b/lib/audit/test/index.js @@ -11,14 +11,37 @@ import groupCase from '../../group/test/group.json' with { type: 'json' } import userCase from '../../user/test/user.json' with { type: 'json' } const zone = { id: 4300, gid: groupCase.id, zone: 'audit.example.com.' } -const seen = { id: 4301, zid: zone.id, owner: 'seen.audit.example.com.', type: 'A', address: '192.0.2.1', ttl: 3600 } -const hidden = { id: 4302, zid: zone.id, owner: 'hidden.audit.example.com.', type: 'A', address: '192.0.2.2', ttl: 3600 } +const seen = { + id: 4301, + zid: zone.id, + owner: 'seen.audit.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const hidden = { + id: 4302, + zid: zone.id, + owner: 'hidden.audit.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} before(async () => { await Group.create(groupCase) await User.create(userCase) await Zone.destroy({ id: zone.id }) - await Zone.create({ ...zone, mailaddr: 'hostmaster.audit.example.com.', serial: 1, refresh: 1, retry: 1, expire: 1, minimum: 1, ttl: 1 }) + await Zone.create({ + ...zone, + mailaddr: 'hostmaster.audit.example.com.', + serial: 1, + refresh: 1, + retry: 1, + expire: 1, + minimum: 1, + ttl: 1, + }) for (const record of [seen, hidden]) { await ZoneRecord.destroy({ id: record.id }) await ZoneRecord.create(record) @@ -40,7 +63,20 @@ describe('audit', () => { it('limits record log rows to the ids a caller may read', async () => { const { rows } = await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] }) - assert.deepEqual(rows.map((r) => r.zrid), [seen.id]) + assert.deepEqual( + rows.map((r) => r.zrid), + [seen.id], + ) + }) + + it('keeps every row when writers overlap', async () => { + const before = (await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] })).rows + const ids = await Promise.all( + [1, 2, 3, 4, 5].map(() => Audit.logZoneRecord(userCase, 'modified', seen, zone)), + ) + assert.equal(new Set(ids).size, ids.length) + const after = (await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] })).rows + assert.equal(after.length, before.length + ids.length) }) it('returns nothing for an empty read scope', async () => { diff --git a/lib/delegation/store/file.js b/lib/delegation/store/file.js index bab3c49..bb50265 100644 --- a/lib/delegation/store/file.js +++ b/lib/delegation/store/file.js @@ -20,8 +20,8 @@ class DelegationRepoFile extends DelegationBase { return this.file.load('delegation') } - async _save(rows) { - return this.file.save('delegation', rows) + async _update(mutate) { + return this.file.update('delegation', mutate) } // delegations join against four entity files; each is a separate document, @@ -40,30 +40,27 @@ class DelegationRepoFile extends DelegationBase { async _present(rows) { const names = await this._activeGroupNames() - return rows.filter((row) => names.has(row.gid)).map((row) => ({ - nt_group_id: row.gid, - nt_object_id: row.oid, - nt_object_type: row.type, - group_name: names.get(row.gid) ?? '', - delegated_by_id: row.delegated_by_id ?? 0, - delegated_by_name: row.delegated_by_name ?? '', - delegate_write: row.perm_write ? 1 : 0, - delegate_delete: row.perm_delete ? 1 : 0, - delegate_delegate: row.perm_delegate ? 1 : 0, - delegate_add_records: row.zone_perm_add_records ? 1 : 0, - delegate_delete_records: row.zone_perm_delete_records ? 1 : 0, - })) + return rows + .filter((row) => names.has(row.gid)) + .map((row) => ({ + nt_group_id: row.gid, + nt_object_id: row.oid, + nt_object_type: row.type, + group_name: names.get(row.gid) ?? '', + delegated_by_id: row.delegated_by_id ?? 0, + delegated_by_name: row.delegated_by_name ?? '', + delegate_write: row.perm_write ? 1 : 0, + delegate_delete: row.perm_delete ? 1 : 0, + delegate_delegate: row.perm_delegate ? 1 : 0, + delegate_add_records: row.zone_perm_add_records ? 1 : 0, + delegate_delete_records: row.zone_perm_delete_records ? 1 : 0, + })) } async create(args) { const { gid, oid, type } = args if (!TYPES[type]) return {} - const rows = await this._load() - if (rows.some((r) => r.gid === gid && r.oid === oid && r.type === type)) { - return { duplicate: true } - } - const row = { gid, oid, @@ -73,8 +70,12 @@ class DelegationRepoFile extends DelegationBase { } for (const f of PERM_FIELDS) row[f] = args[f] === true - rows.push(row) - await this._save(rows) + const created = await this._update((rows) => { + if (rows.some((r) => r.gid === gid && r.oid === oid && r.type === type)) return false + rows.push(row) + return true + }) + if (!created) return { duplicate: true } await this.writeLog({ ...row, ...permsToInt(row) }, 'delegated') @@ -83,9 +84,7 @@ class DelegationRepoFile extends DelegationBase { async getDelegated(gid, type) { if (!TYPES[type]) return [] - const rows = (await this._load()).filter( - (r) => r.gid === gid && r.type === type, - ) + const rows = (await this._load()).filter((r) => r.gid === gid && r.type === type) const active = [] for (const row of rows) { if (await this._object(type, row.oid)) active.push(row) @@ -98,27 +97,25 @@ class DelegationRepoFile extends DelegationBase { async getDelegates(oid, type, gid) { if (!TYPES[type]) return [] - let rows = (await this._load()).filter( - (r) => r.oid === oid && r.type === type, - ) + let rows = (await this._load()).filter((r) => r.oid === oid && r.type === type) if (gid !== undefined) rows = rows.filter((r) => r.gid === gid) return this._present(rows) } async put(args) { const { gid, oid, type } = args - const rows = await this._load() - const row = rows.find((r) => r.gid === gid && r.oid === oid && r.type === type) - if (!row) return null - const updates = {} for (const f of PERM_FIELDS) { if (args[f] !== undefined) updates[f] = args[f] === true } - if (Object.keys(updates).length === 0) return true - Object.assign(row, updates) - await this._save(rows) + const row = await this._update((rows) => { + const found = rows.find((r) => r.gid === gid && r.oid === oid && r.type === type) + if (found) Object.assign(found, updates) + return found ?? null + }) + if (!row) return null + if (Object.keys(updates).length === 0) return true await this.writeLog( { ...row, delegated_by_id: args.delegated_by_id, delegated_by_name: args.delegated_by_name }, @@ -130,13 +127,12 @@ class DelegationRepoFile extends DelegationBase { async delete(args) { const { gid, oid, type } = args - let rows = await this._load() - const row = rows.find((r) => r.gid === gid && r.oid === oid && r.type === type) + const row = await this._update((rows) => { + const at = rows.findIndex((r) => r.gid === gid && r.oid === oid && r.type === type) + return at === -1 ? null : rows.splice(at, 1)[0] + }) if (!row) return null - rows = rows.filter((r) => r !== row) - await this._save(rows) - await this.writeLog( { ...row, delegated_by_id: args.delegated_by_id, delegated_by_name: args.delegated_by_name }, 'deleted', @@ -146,22 +142,22 @@ class DelegationRepoFile extends DelegationBase { } async writeLog(data, action) { - const logs = await this.logFile.load('delegate_log') - logs.push({ - nt_user_id: data.delegated_by_id ?? 0, - nt_user_name: data.delegated_by_name ?? '', - action, - nt_object_type: data.nt_object_type ?? data.type, - nt_object_id: data.nt_object_id ?? data.oid, - nt_group_id: data.nt_group_id ?? data.gid, - timestamp: Math.floor(Date.now() / 1000), - perm_write: (data.perm_write ?? true) ? 1 : 0, - perm_delete: (data.perm_delete ?? true) ? 1 : 0, - perm_delegate: (data.perm_delegate ?? true) ? 1 : 0, - zone_perm_add_records: (data.zone_perm_add_records ?? true) ? 1 : 0, - zone_perm_delete_records: (data.zone_perm_delete_records ?? true) ? 1 : 0, - }) - await this.logFile.save('delegate_log', logs) + await this.logFile.update('delegate_log', (logs) => + logs.push({ + nt_user_id: data.delegated_by_id ?? 0, + nt_user_name: data.delegated_by_name ?? '', + action, + nt_object_type: data.nt_object_type ?? data.type, + nt_object_id: data.nt_object_id ?? data.oid, + nt_group_id: data.nt_group_id ?? data.gid, + timestamp: Math.floor(Date.now() / 1000), + perm_write: (data.perm_write ?? true) ? 1 : 0, + perm_delete: (data.perm_delete ?? true) ? 1 : 0, + perm_delegate: (data.perm_delegate ?? true) ? 1 : 0, + zone_perm_add_records: (data.zone_perm_add_records ?? true) ? 1 : 0, + zone_perm_delete_records: (data.zone_perm_delete_records ?? true) ? 1 : 0, + }), + ) } } diff --git a/lib/store/file.js b/lib/store/file.js index 8ec2c9e..2818615 100644 --- a/lib/store/file.js +++ b/lib/store/file.js @@ -20,6 +20,9 @@ const tomlCodec = { const codecs = { json: jsonCodec, toml: tomlCodec } +// one writer per file at a time, whichever FileStore instance holds it +const writeQueues = new Map() + export function resolveCodec(type = storeConfig().type) { return codecs[type] ?? jsonCodec } @@ -68,6 +71,30 @@ export class FileStore { await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, await this.codec.stringify({ [key]: rows })) } + + /** + * Load, mutate in place, save — serialized per file (not re-entrant) so concurrent writers + * cannot overwrite each other's rows. Resolves to whatever mutate returns. + */ + async update(key, mutate) { + const previous = writeQueues.get(this.basename) ?? Promise.resolve() + let release + writeQueues.set( + this.basename, + new Promise((resolve) => { + release = resolve + }), + ) + await previous + try { + const rows = await this.load(key) + const result = await mutate(rows) + await this.save(key, rows) + return result + } finally { + release() + } + } } export default FileStore From 4f05c0d7e4639a05cedd19809a377e674625d65c Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:21:32 +0100 Subject: [PATCH 37/48] zone: allocate ids in the file store Creating a zone without an id stored and returned id 0 where mysql hands back its auto-increment id. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/zone/store/file.js | 13 +++++++++---- lib/zone/test/index.js | 12 ++++++++++++ lib/zone/test/mysql.js | 9 --------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index f6d9f10..8066a25 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -8,7 +8,9 @@ let zoneWriteQueue = Promise.resolve() async function withZoneWriteLock(fn) { const previous = zoneWriteQueue let release - zoneWriteQueue = new Promise((resolve) => { release = resolve }) + zoneWriteQueue = new Promise((resolve) => { + release = resolve + }) await previous try { return await fn() @@ -19,9 +21,11 @@ async function withZoneWriteLock(fn) { function assertZoneNameAvailable(zones, zone, excludeId) { const canonical = canonicalZoneName(zone) - if (zones.some((row) => ( - row.id !== excludeId && row.deleted !== true && canonicalZoneName(row.zone) === canonical - ))) { + if ( + zones.some( + (row) => row.id !== excludeId && row.deleted !== true && canonicalZoneName(row.zone) === canonical, + ) + ) { throw new ZoneNameConflictError(zone) } } @@ -75,6 +79,7 @@ class ZoneRepoFile extends ZoneBase { const zones = await this._load() if (zone.id && zones.some((z) => z.id === zone.id)) return zone.id if (zone.deleted !== true) assertZoneNameAvailable(zones, zone.zone) + if (!zone.id) zone.id = zones.reduce((max, z) => Math.max(max, z.id ?? 0), 0) + 1 zones.push(JSON.parse(JSON.stringify(zone))) await this._save(zones) if (Array.isArray(nameservers)) await this._replaceNs(zone.id, nameservers) diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index 5a3c3fa..0d65a80 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -63,6 +63,18 @@ describe('zone', function () { assert.deepEqual(await Zone.nameserverIds(testCase.id), []) }) + it('assigns nameservers to an allocated id', async () => { + const id = await Zone.create({ + ...testCase, + id: 0, + zone: 'auto-ns.example.com', + nameservers: [4095], + }) + assert.ok(id > 0) + assert.deepEqual(await Zone.nameserverIds(id), [4095]) + assert.ok(await Zone.destroy({ id })) + }) + it('stores the assignment on create and drops it on destroy', async () => { const id = testCase.id + 1 await Zone.destroy({ id }) diff --git a/lib/zone/test/mysql.js b/lib/zone/test/mysql.js index c1fac45..8cee95a 100644 --- a/lib/zone/test/mysql.js +++ b/lib/zone/test/mysql.js @@ -31,15 +31,6 @@ describe('zone (mysql)', function () { assert.notEqual(lockName, zoneLockName('other.example.com')) }) - it('assigns nameservers to an auto-increment id', async () => { - const id = await Zone.create({ - ...testCase, id: 0, zone: 'auto-ns.example.com', nameservers: [4095], - }) - assert.ok(id > 0) - assert.deepEqual(await Zone.nameserverIds(id), [4095]) - assert.ok(await Zone.destroy({ id })) - }) - it('handles null minimum gracefully', async () => { await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) From 3d41527aa781fd159163a31508cf09aac6c58235 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:21:32 +0100 Subject: [PATCH 38/48] authz: cover record-scoped history reads The log scope fix had no test showing a record-only delegate sees only its own record's history. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- routes/authz.test.js | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/routes/authz.test.js b/routes/authz.test.js index 9854eab..c9c368a 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -8,6 +8,7 @@ import Zone from '../lib/zone/index.js' import ZoneRecord from '../lib/zone_record/index.js' import Nameserver from '../lib/nameserver/index.js' import Permission from '../lib/permission/index.js' +import Audit from '../lib/audit/index.js' import Delegation from '../lib/delegation/index.js' import Mysql from '../lib/mysql.js' @@ -800,18 +801,22 @@ describe('authz plugin - delegation routes', () => { assert.equal(res.result.delegation[0].nt_group_id, G_CHILD.id) }) - it('creates one delegation when identical requests race', { - skip: (process.env.NICTOOL_DATA_STORE ?? 'mysql') !== 'mysql', - }, async () => { - const args = { gid: G_CHILD.id, oid: ZR_INTREE_OTHER.id, type: 'ZONERECORD' } - await Delegation.delete(args) - const results = await Promise.all([1, 2, 3].map(() => Delegation.create(args))) - assert.equal(results.filter((r) => r.created).length, 1) - assert.equal(results.filter((r) => r.duplicate).length, 2) - const rows = await Delegation.get(args) - assert.equal(rows.length, 1) - await Delegation.delete(args) - }) + it( + 'creates one delegation when identical requests race', + { + skip: (process.env.NICTOOL_DATA_STORE ?? 'mysql') !== 'mysql', + }, + async () => { + const args = { gid: G_CHILD.id, oid: ZR_INTREE_OTHER.id, type: 'ZONERECORD' } + await Delegation.delete(args) + const results = await Promise.all([1, 2, 3].map(() => Delegation.create(args))) + assert.equal(results.filter((r) => r.created).length, 1) + assert.equal(results.filter((r) => r.duplicate).length, 2) + const rows = await Delegation.get(args) + assert.equal(rows.length, 1) + await Delegation.delete(args) + }, + ) it('cannot delegate an object back to your own group', async () => { const res = await server.inject({ @@ -1176,6 +1181,17 @@ describe('authz plugin - delegation type and pseudo access', () => { ) assert.equal(records.result.meta.pagination.total, 1) + await Audit.logZoneRecord(U_FULL, 'modified', ZR_INTREE, Z_INTREE) + await Audit.logZoneRecord(U_FULL, 'modified', ZR_INTREE_OTHER, Z_INTREE) + const log = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(log.statusCode, 200) + assert.ok(log.result.log.length > 0) + assert.deepEqual(new Set(log.result.log.map((row) => row.zrid)), new Set([ZR_INTREE.id])) + const write = await server.inject({ method: 'PUT', url: `/zone/${Z_INTREE.id}`, From c3e23d75a4eabca5df2cdd9c0d37b24eb22ba683 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:56:39 +0100 Subject: [PATCH 39/48] delegation: test racing creates on the json store routes/authz.test.js only races creates on mysql. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/file-stores.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/file-stores.test.js b/lib/file-stores.test.js index b6ea85c..17b4b3b 100644 --- a/lib/file-stores.test.js +++ b/lib/file-stores.test.js @@ -36,6 +36,15 @@ after(() => { }) describe('file-store delegation', () => { + it('creates one delegation when identical requests race', async () => { + const FileDelegation = new (await freshStores().then(([d]) => d.default))() + const args = { gid: 42, oid: 8, type: 'ZONE' } + const results = await Promise.all([1, 2, 3].map(() => FileDelegation.create(args))) + assert.equal(results.filter((r) => r.created).length, 1) + assert.equal(results.filter((r) => r.duplicate).length, 2) + assert.ok(await FileDelegation.delete(args)) + }) + it('creates, lists, updates, and deletes a delegation', async () => { const FileDelegation = new (await freshStores().then(([d]) => d.default))() await seedEntity('group', [{ id: 42, name: 'delegate holder' }]) From ca76ae43116611906284ddb86c6f210ef4586baf Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:57:56 +0100 Subject: [PATCH 40/48] chore: prettier the branch Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- lib/audit/store/mysql.js | 59 ++++++---- lib/authz-plugin.js | 152 ++++++++++++------------- lib/authz.test.js | 196 ++++++++++++++++----------------- lib/authz/store/base.js | 84 ++++++-------- lib/authz/store/file.js | 6 +- lib/authz/store/mysql.js | 18 ++- lib/delegation/store/mysql.js | 4 +- lib/group/store/mysql.js | 4 +- lib/page.js | 5 +- lib/permission/store/file.js | 14 ++- lib/permission/store/mysql.js | 22 +++- lib/permission/test/index.js | 24 ++-- lib/session/store/file.js | 4 +- lib/store-access.test.js | 12 +- lib/zone/store/mysql.js | 21 ++-- lib/zone_record/store/mysql.js | 5 +- lib/zone_record/test/index.js | 14 ++- routes/delegation.js | 13 +-- routes/log.js | 4 +- routes/permission.js | 8 +- routes/zone.js | 4 +- routes/zone_record.js | 18 +-- 22 files changed, 328 insertions(+), 363 deletions(-) diff --git a/lib/audit/store/mysql.js b/lib/audit/store/mysql.js index 1823334..4aace97 100644 --- a/lib/audit/store/mysql.js +++ b/lib/audit/store/mysql.js @@ -15,23 +15,23 @@ class AuditRepoMysql extends AuditBase { } async insertGlobalLog({ uid, timestamp, action, object, objectId, logId, title, description }) { - return Mysql.execute(...Mysql.insert('nt_user_global_log', { - nt_user_id: uid, - timestamp, - action, - object, - object_id: objectId, - log_entry_id: logId, - title, - description, - })) + return Mysql.execute( + ...Mysql.insert('nt_user_global_log', { + nt_user_id: uid, + timestamp, + action, + object, + object_id: objectId, + log_entry_id: logId, + title, + description, + }), + ) } async listGlobal(args) { const scope = groupScope('u.nt_group_id', args.gids) - const where = Number.isInteger(args.uid) - ? `${scope.sql} AND gl.nt_user_id = ?` - : scope.sql + const where = Number.isInteger(args.uid) ? `${scope.sql} AND gl.nt_user_id = ?` : scope.sql const params = Number.isInteger(args.uid) ? [...scope.params, args.uid] : scope.params return listRows({ select: `SELECT gl.nt_user_global_log_id AS id, @@ -46,8 +46,12 @@ class AuditRepoMysql extends AuditBase { params, searchColumns: ['u.username', 'gl.action', 'gl.object', 'gl.title', 'gl.description'], sortMap: { - timestamp: 'gl.timestamp', user: 'u.username', action: 'gl.action', - object: 'gl.object', title: 'gl.title', description: 'gl.description', + timestamp: 'gl.timestamp', + user: 'u.username', + action: 'gl.action', + object: 'gl.object', + title: 'gl.title', + description: 'gl.description', group_name: 'g.name', }, args, @@ -69,8 +73,13 @@ class AuditRepoMysql extends AuditBase { params: scope.params, searchColumns: ['zl.zone', 'zl.description', 'zl.action', 'u.username', 'g.name'], sortMap: { - timestamp: 'zl.timestamp', user: 'u.username', action: 'zl.action', - zone: 'zl.zone', ttl: 'zl.ttl', description: 'zl.description', group_name: 'g.name', + timestamp: 'zl.timestamp', + user: 'u.username', + action: 'zl.action', + zone: 'zl.zone', + ttl: 'zl.ttl', + description: 'zl.description', + group_name: 'g.name', }, args, }) @@ -99,13 +108,17 @@ class AuditRepoMysql extends AuditBase { JOIN resource_record_type rt ON rt.id = rl.type_id`, where, params, - searchColumns: [ - 'rl.name', 'rl.description', 'rt.name', 'rl.address', 'rl.action', 'u.username', - ], + searchColumns: ['rl.name', 'rl.description', 'rt.name', 'rl.address', 'rl.action', 'u.username'], sortMap: { - timestamp: 'rl.timestamp', user: 'u.username', action: 'rl.action', - owner: 'rl.name', type: 'rt.name', address: 'rl.address', ttl: 'rl.ttl', - weight: 'rl.weight', description: 'rl.description', + timestamp: 'rl.timestamp', + user: 'u.username', + action: 'rl.action', + owner: 'rl.name', + type: 'rt.name', + address: 'rl.address', + ttl: 'rl.ttl', + weight: 'rl.weight', + description: 'rl.description', }, args, }) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index 41f46cc..6b2d0b0 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -23,10 +23,13 @@ const authzPlugin = { if (request.auth.isAuthenticated && !isLogin) { const credentials = await Authz.getCurrentCredentials(request.auth.credentials) if (!credentials) { - return h.response({ - error_code: 401, - error_msg: 'Session is no longer valid', - }).code(401).takeover() + return h + .response({ + error_code: 401, + error_msg: 'Session is no longer valid', + }) + .code(401) + .takeover() } request.auth.credentials = credentials // idle expiry must measure activity; Session.put throttles to once a minute @@ -44,11 +47,14 @@ const authzPlugin = { const credentials = request.auth.credentials if (resource === 'permission') { - const result = action === 'create' - ? await Authz.checkPermissionTarget(credentials, request.payload) - : await Authz.checkPermissionRecord( - credentials, action, Number(resolveId(request, permCfg.idFrom)), - ) + const result = + action === 'create' + ? await Authz.checkPermissionTarget(credentials, request.payload) + : await Authz.checkPermissionRecord( + credentials, + action, + Number(resolveId(request, permCfg.idFrom)), + ) return respond(result, h) } @@ -60,14 +66,15 @@ const authzPlugin = { } if (request.query.oid !== undefined) { const object = await Authz.checkPermission( - credentials, delegatedResource, 'read', Number(request.query.oid), + credentials, + delegatedResource, + 'read', + Number(request.query.oid), ) if (!object.allowed) return respond(object, h) } if (request.query.gid !== undefined) { - const group = await Authz.checkPermission( - credentials, 'group', 'read', Number(request.query.gid), - ) + const group = await Authz.checkPermission(credentials, 'group', 'read', Number(request.query.gid)) if (!group.allowed) return respond(group, h) } if (request.query.oid === undefined && request.query.gid === undefined) { @@ -85,45 +92,38 @@ const authzPlugin = { if (permCfg.targetGroupFrom) { const targetGid = resolveId(request, permCfg.targetGroupFrom) // a PUT that keeps the current group is an edit, not a move - const currentGid = targetGid === undefined - ? null - : await Authz.getObjectGroupId(resource, objectId) + const currentGid = targetGid === undefined ? null : await Authz.getObjectGroupId(resource, objectId) if (targetGid !== undefined && Number(targetGid) !== currentGid) { if ( - resource === 'user' - && action === 'write' - && objectId === credentials.user.id - && Number(targetGid) !== credentials.group.id + resource === 'user' && + action === 'write' && + objectId === credentials.user.id && + Number(targetGid) !== credentials.group.id ) { - return respond({ - allowed: false, - code: 403, - msg: `Cannot move yourself to another group`, - }, h) + return respond( + { + allowed: false, + code: 403, + msg: `Cannot move yourself to another group`, + }, + h, + ) } if ( - resource === 'group' - && action === 'write' - && ( - Number(targetGid) === objectId - || await Authz.isInGroupTree(objectId, Number(targetGid)) - ) + resource === 'group' && + action === 'write' && + (Number(targetGid) === objectId || (await Authz.isInGroupTree(objectId, Number(targetGid)))) ) { return respond({ allowed: false, code: 404, msg: `A group cannot contain itself` }, h) } - if (!await Authz.isActiveGroup(Number(targetGid))) { + if (!(await Authz.isActiveGroup(Number(targetGid)))) { return respond({ allowed: false, code: 404, msg: `Target group is deleted` }, h) } - const target = await Authz.checkPermission( - credentials, 'group', 'read', Number(targetGid), - ) + const target = await Authz.checkPermission(credentials, 'group', 'read', Number(targetGid)) if (!target.allowed) return respond(target, h) // moves stay inside the owning tree, as in v2 - if ( - currentGid !== null - && !await Authz.isInGroupTree(credentials.group.id, currentGid) - ) { + if (currentGid !== null && !(await Authz.isInGroupTree(credentials.group.id, currentGid))) { return respond({ allowed: false, code: 404, msg: `Cannot move a delegated ${resource}` }, h) } } @@ -133,9 +133,7 @@ const authzPlugin = { if (sourceZid !== null) { // moving out of a zone needs delete rights where the record lives, // not just create rights where it's going - const source = await Authz.checkPermission( - credentials, resource, 'delete', objectId, - ) + const source = await Authz.checkPermission(credentials, resource, 'delete', objectId) if (!source.allowed) return respond(source, h) const target = await resolveTargetGroup(request, permCfg.targetCreateResource) @@ -158,11 +156,14 @@ const authzPlugin = { objectId = credentials.group.id } if (objectId === undefined) { - return respond({ - allowed: false, - code: 404, - msg: `A scoped collection id is required`, - }, h) + return respond( + { + allowed: false, + code: 404, + msg: `A scoped collection id is required`, + }, + h, + ) } objectId = Number(objectId) } @@ -172,29 +173,33 @@ const authzPlugin = { // only zones and zone records are delegable; nothing caps the granted // permissions for the other nt_delegate types, so refuse them outright if (!DELEGABLE_RESOURCE[type]) { - return respond({ - allowed: false, - code: 404, - msg: `${type} objects cannot be delegated`, - }, h) + return respond( + { + allowed: false, + code: 404, + msg: `${type} objects cannot be delegated`, + }, + h, + ) } resource = DELEGABLE_RESOURCE[type] const targetGid = request.payload?.gid ?? request.query?.gid if (targetGid !== undefined) { - if (!await Authz.isActiveGroup(Number(targetGid))) { + if (!(await Authz.isActiveGroup(Number(targetGid)))) { return respond({ allowed: false, code: 404, msg: `Delegation target group is deleted` }, h) } - const target = await Authz.checkPermission( - credentials, 'group', 'read', Number(targetGid), - ) + const target = await Authz.checkPermission(credentials, 'group', 'read', Number(targetGid)) if (!target.allowed) return respond(target, h) if (request.method === 'post' && Number(targetGid) === credentials.group.id) { - return respond({ - allowed: false, - code: 404, - msg: `Cannot delegate to your own group`, - }, h) + return respond( + { + allowed: false, + code: 404, + msg: `Cannot delegate to your own group`, + }, + h, + ) } } } @@ -207,28 +212,22 @@ const authzPlugin = { return respond({ allowed: false, code: 404, msg: `That ${resource} id already exists` }, h) } } - const target = await resolveTargetGroup( - request, resource, - ) + const target = await resolveTargetGroup(request, resource) opts = { targetGroupId: target?.gid, targetZoneId: target?.zid, } } - const result = await Authz.checkPermission( - credentials, resource, action, objectId, opts, - ) + const result = await Authz.checkPermission(credentials, resource, action, objectId, opts) if (!result.allowed) return respond(result, h) // Only a PUT that actually flips the deleted flag is a delete; a client // that echoes the object back unchanged needs no delete permission. if (action === 'write' && request.payload?.deleted !== undefined) { - const wasDeleted = !await Authz.isActiveObject(resource, objectId) + const wasDeleted = !(await Authz.isActiveObject(resource, objectId)) if (Boolean(request.payload.deleted) !== wasDeleted) { - const deleteResult = await Authz.checkPermission( - credentials, resource, 'delete', objectId, - ) + const deleteResult = await Authz.checkPermission(credentials, resource, 'delete', objectId) return respond(deleteResult, h) } } @@ -240,10 +239,13 @@ const authzPlugin = { function respond(result, h) { if (result.allowed) return h.continue - return h.response({ - error_code: result.code, - error_msg: result.msg, - }).code(403).takeover() + return h + .response({ + error_code: result.code, + error_msg: result.msg, + }) + .code(403) + .takeover() } function resolveId(request, idFrom) { diff --git a/lib/authz.test.js b/lib/authz.test.js index a324742..ee75d6b 100644 --- a/lib/authz.test.js +++ b/lib/authz.test.js @@ -129,7 +129,11 @@ before(async () => { { gid: 4200, oid: 4202, type: 'ZONERECORD' }, { gid: 4200, oid: 4201, type: 'ZONE' }, ]) { - try { await Delegation.delete(d) } catch { /* ignore */ } + try { + await Delegation.delete(d) + } catch { + /* ignore */ + } } for (const id of [4200, 4201, 4202]) { await ZoneRecord.destroy({ id }) @@ -142,10 +146,7 @@ before(async () => { await User.destroy({ id }) } for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) - await Mysql.execute( - 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', - [4200, 4201, 4202], - ) + await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) for (const u of [U_FULL, U_LIMITED, U_NOSELF]) await User.create(u) @@ -156,12 +157,23 @@ before(async () => { await Permission.put({ id: fullPerm.id, self_write: 1, - group_write: 1, group_create: 1, group_delete: 1, - zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, - zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + group_write: 1, + group_create: 1, + group_delete: 1, + zone_write: 1, + zone_create: 1, + zone_delete: 1, + zone_delegate: 1, + zonerecord_write: 1, + zonerecord_create: 1, + zonerecord_delete: 1, zonerecord_delegate: 1, - user_write: 1, user_create: 1, user_delete: 1, - nameserver_write: 1, nameserver_create: 1, nameserver_delete: 1, + user_write: 1, + user_create: 1, + user_delete: 1, + nameserver_write: 1, + nameserver_create: 1, + nameserver_delete: 1, usable_ns: '4200', }) } @@ -172,12 +184,23 @@ before(async () => { await Permission.put({ id: limPerm.id, self_write: 0, - group_write: 0, group_create: 0, group_delete: 0, - zone_write: 0, zone_create: 0, zone_delete: 0, zone_delegate: 0, - zonerecord_write: 0, zonerecord_create: 0, zonerecord_delete: 0, + group_write: 0, + group_create: 0, + group_delete: 0, + zone_write: 0, + zone_create: 0, + zone_delete: 0, + zone_delegate: 0, + zonerecord_write: 0, + zonerecord_create: 0, + zonerecord_delete: 0, zonerecord_delegate: 0, - user_write: 0, user_create: 0, user_delete: 0, - nameserver_write: 0, nameserver_create: 0, nameserver_delete: 0, + user_write: 0, + user_create: 0, + user_delete: 0, + nameserver_write: 0, + nameserver_create: 0, + nameserver_delete: 0, usable_ns: '', }) } @@ -188,10 +211,17 @@ before(async () => { await Permission.put({ id: noselfPerm.id, self_write: 0, - zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, - zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zone_write: 1, + zone_create: 1, + zone_delete: 1, + zone_delegate: 1, + zonerecord_write: 1, + zonerecord_create: 1, + zonerecord_delete: 1, zonerecord_delegate: 1, - user_write: 1, user_create: 1, user_delete: 1, + user_write: 1, + user_create: 1, + user_delete: 1, }) } @@ -205,12 +235,20 @@ before(async () => { // Create delegations await Delegation.create({ - gid: 4200, oid: 4201, type: 'ZONE', - perm_write: true, perm_delete: false, perm_delegate: true, + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_write: true, + perm_delete: false, + perm_delegate: true, }) await Delegation.create({ - gid: 4200, oid: 4202, type: 'ZONERECORD', - perm_write: true, perm_delete: false, perm_delegate: false, + gid: 4200, + oid: 4202, + type: 'ZONERECORD', + perm_write: true, + perm_delete: false, + perm_delegate: false, }) }) @@ -233,44 +271,30 @@ after(async () => { await Group.destroy({ id: g.id }) } // Clean up subgroup entries - await Mysql.execute( - 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', - [4200, 4201, 4202], - ) + await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) await Mysql.disconnect() }) describe('checkPermission', () => { describe('create actions', () => { it('allows create when user has permission', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'create', undefined, - { targetGroupId: 4200 }, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'create', undefined, { targetGroupId: 4200 }) assert.equal(r.allowed, true) }) it('allows create into child group', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'create', undefined, - { targetGroupId: 4201 }, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'create', undefined, { targetGroupId: 4201 }) assert.equal(r.allowed, true) }) it('denies create when user lacks permission', async () => { - const r = await Authz.checkPermission( - credsLimited, 'zone', 'create', undefined, - ) + const r = await Authz.checkPermission(credsLimited, 'zone', 'create', undefined) assert.equal(r.allowed, false) assert.match(r.msg, /Not allowed to create/) }) it('denies create when target group not in tree', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'create', undefined, - { targetGroupId: 4202 }, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'create', undefined, { targetGroupId: 4202 }) assert.equal(r.allowed, false) assert.match(r.msg, /No Access Allowed/) }) @@ -278,49 +302,37 @@ describe('checkPermission', () => { describe('self-user restrictions', () => { it('denies delete self', async () => { - const r = await Authz.checkPermission( - credsFull, 'user', 'delete', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'user', 'delete', 4200) assert.equal(r.allowed, false) assert.match(r.msg, /Not allowed to delete self/) }) it('allows write self when self_write=true', async () => { - const r = await Authz.checkPermission( - credsFull, 'user', 'write', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'user', 'write', 4200) assert.equal(r.allowed, true) }) it('denies write self when self_write=false', async () => { - const r = await Authz.checkPermission( - credsNoself, 'user', 'write', 4202, - ) + const r = await Authz.checkPermission(credsNoself, 'user', 'write', 4202) assert.equal(r.allowed, false) assert.match(r.msg, /Not allowed to modify self/) }) it('allows read self', async () => { - const r = await Authz.checkPermission( - credsFull, 'user', 'read', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'user', 'read', 4200) assert.equal(r.allowed, true) }) }) describe('own-group restrictions', () => { it('denies write to own group', async () => { - const r = await Authz.checkPermission( - credsFull, 'group', 'write', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'group', 'write', 4200) assert.equal(r.allowed, false) assert.match(r.msg, /Not allowed to edit your own group/) }) it('denies delete own group', async () => { - const r = await Authz.checkPermission( - credsFull, 'group', 'delete', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'group', 'delete', 4200) assert.equal(r.allowed, false) assert.match(r.msg, /Not allowed to delete your own group/) }) @@ -328,101 +340,83 @@ describe('checkPermission', () => { describe('nameserver reads', () => { it('allows authenticated read of an active nameserver', async () => { - const r = await Authz.checkPermission( - credsLimited, 'nameserver', 'read', 4200, - ) + const r = await Authz.checkPermission(credsLimited, 'nameserver', 'read', 4200) assert.equal(r.allowed, true) }) }) describe('group tree ownership', () => { it('allows read of in-tree zone', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'read', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'read', 4200) assert.equal(r.allowed, true) }) it('allows write of in-tree zone with permission', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'write', 4200, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'write', 4200) assert.equal(r.allowed, true) }) it('denies write when user lacks action permission', async () => { - const r = await Authz.checkPermission( - credsLimited, 'zone', 'write', 4201, - ) + const r = await Authz.checkPermission(credsLimited, 'zone', 'write', 4201) assert.equal(r.allowed, false) }) }) describe('delegation access', () => { it('allows read of delegated zone', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'read', 4201, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'read', 4201) assert.equal(r.allowed, true) }) it('allows write of delegated zone when perm_write=1', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'write', 4201, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'write', 4201) assert.equal(r.allowed, true) }) it('denies delete of delegated zone when perm_delete=0', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'delete', 4201, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'delete', 4201) assert.equal(r.allowed, false) assert.match(r.msg, /no 'delete' permission/) }) it('perm_delete never permits deleting the delegated object', async () => { await Delegation.put({ - gid: 4200, oid: 4201, type: 'ZONE', perm_delete: true, + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_delete: true, }) - const r = await Authz.checkPermission( - credsFull, 'zone', 'delete', 4201, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'delete', 4201) assert.equal(r.allowed, false) await Delegation.put({ - gid: 4200, oid: 4201, type: 'ZONE', perm_delete: false, + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_delete: false, }) }) it('allows delegate action when perm_delegate=1', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'delegate', 4201, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'delegate', 4201) assert.equal(r.allowed, true) }) }) describe('pseudo-delegation (zone record via parent zone)', () => { it('allows read of zone record in delegated zone', async () => { - const r = await Authz.checkPermission( - credsFull, 'zonerecord', 'read', 4201, - ) + const r = await Authz.checkPermission(credsFull, 'zonerecord', 'read', 4201) assert.equal(r.allowed, true) }) }) describe('direct zone record delegation', () => { it('allows read of directly delegated zone record', async () => { - const r = await Authz.checkPermission( - credsFull, 'zonerecord', 'read', 4202, - ) + const r = await Authz.checkPermission(credsFull, 'zonerecord', 'read', 4202) assert.equal(r.allowed, true) }) it('denies delete when perm_delete=0', async () => { - const r = await Authz.checkPermission( - credsFull, 'zonerecord', 'delete', 4202, - ) + const r = await Authz.checkPermission(credsFull, 'zonerecord', 'delete', 4202) assert.equal(r.allowed, false) assert.match(r.msg, /no 'delete' permission/) }) @@ -430,17 +424,13 @@ describe('checkPermission', () => { describe('deny fallthrough', () => { it('denies access to object not in tree and not delegated', async () => { - const r = await Authz.checkPermission( - credsLimited, 'zone', 'read', 4200, - ) + const r = await Authz.checkPermission(credsLimited, 'zone', 'read', 4200) assert.equal(r.allowed, false) assert.match(r.msg, /No Access Allowed/) }) it('denies when object does not exist', async () => { - const r = await Authz.checkPermission( - credsFull, 'zone', 'read', 99999, - ) + const r = await Authz.checkPermission(credsFull, 'zone', 'read', 99999) assert.equal(r.allowed, false) assert.match(r.msg, /No Access Allowed/) }) diff --git a/lib/authz/store/base.js b/lib/authz/store/base.js index a76190b..028fe6d 100644 --- a/lib/authz/store/base.js +++ b/lib/authz/store/base.js @@ -28,16 +28,26 @@ const DELEGATE_TYPE = { } const PERM_FIELDS = [ - 'group_write', 'group_create', 'group_delete', - 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', - 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', - 'user_write', 'user_create', 'user_delete', - 'nameserver_write', 'nameserver_create', 'nameserver_delete', + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', ] -const CREATE_REQUIRES_GROUP = new Set([ - 'group', 'nameserver', 'user', 'zone', 'zonerecord', -]) +const CREATE_REQUIRES_GROUP = new Set(['group', 'nameserver', 'user', 'zone', 'zonerecord']) const ACTION_PERMISSION = { editDelegation: 'delegate', @@ -61,25 +71,20 @@ class AuthzBase { return deny(`No target group found for new ${resource}`) } } else { - if (!await this.isActiveGroup(targetGid)) { + if (!(await this.isActiveGroup(targetGid))) { return deny(`No active target group found for new ${resource}`) } - const inTree = await this.isInGroupTree( - credentials.group.id, targetGid, - ) + const inTree = await this.isInGroupTree(credentials.group.id, targetGid) if (!inTree) { if (resource === 'zonerecord' && opts?.targetZoneId) { - const delegation = await this.getDelegateAccess( - credentials.group.id, opts.targetZoneId, 'zone', - ) + const delegation = await this.getDelegateAccess(credentials.group.id, opts.targetZoneId, 'zone') if (delegation?.zone_perm_add_records === 1) return allow() if (delegation) { return deny(`Not allowed to add records to the delegated zone.`) } } return deny( - `No Access Allowed to that object` - + ` (${DELEGATE_TYPE[resource] ?? 'GROUP'} : ${targetGid})`, + `No Access Allowed to that object` + ` (${DELEGATE_TYPE[resource] ?? 'GROUP'} : ${targetGid})`, ) } } @@ -101,17 +106,13 @@ class AuthzBase { if (action === 'read') return allow() } - if ( - resource === 'nameserver' - && action === 'read' - && await this.isActiveObject(resource, objectId) - ) { + if (resource === 'nameserver' && action === 'read' && (await this.isActiveObject(resource, objectId))) { return allow() } if ( - ['delegate', 'editDelegation', 'deleteDelegation'].includes(action) - && !await this.isActiveObject(resource, objectId) + ['delegate', 'editDelegation', 'deleteDelegation'].includes(action) && + !(await this.isActiveObject(resource, objectId)) ) { return deny(`Cannot change delegation for a deleted object`) } @@ -128,9 +129,7 @@ class AuthzBase { return deny(`You have no '${action}' permission for ${resource} objects`) } - const delegation = await this.getDelegateAccess( - credentials.group.id, objectId, resource, - ) + const delegation = await this.getDelegateAccess(credentials.group.id, objectId, resource) if (delegation) { if (action === 'read') return allow({ delegation }) const displayAction = ACTION_PERMISSION[action] ?? action @@ -149,16 +148,12 @@ class AuthzBase { if (action === 'delete') { return deny(`You have no '${action}' permission for the delegated object`) } - const permField = action === 'deleteDelegation' - ? 'perm_delete' - : `perm_${action}` + const permField = action === 'deleteDelegation' ? 'perm_delete' : `perm_${action}` if (delegation[permField] === 1) return allow() return deny(`You have no '${displayAction}' permission for the delegated object`) } - return deny( - `No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`, - ) + return deny(`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`) } async getDelegateAccess(groupId, objectId, resource) { @@ -232,9 +227,7 @@ class AuthzBase { capped.usable_ns = capUsableNameservers(capped.usable_ns, usable, existingUsable) } if (Array.isArray(capped.nameserver?.usable)) { - capped.nameserver.usable = capUsableNameservers( - capped.nameserver.usable, usable, existingUsable, - ) + capped.nameserver.usable = capUsableNameservers(capped.nameserver.usable, usable, existingUsable) } return capped } @@ -249,10 +242,7 @@ class AuthzBase { } } - if ( - userPerm.user?.write !== true - && Boolean(before?.self_write) !== Boolean(after?.self_write) - ) { + if (userPerm.user?.write !== true && Boolean(before?.self_write) !== Boolean(after?.self_write)) { return false } @@ -283,9 +273,7 @@ class AuthzBase { async getCurrentCredentials(credentials) { // Match the JWT's maximum token age when checking server-side revocation. const oldest = Math.floor(Date.now() / 1000) - SESSION_MAX_AGE_SEC - const gid = await this.liveSessionGroup( - credentials.user.id, credentials.session.id, oldest, - ) + const gid = await this.liveSessionGroup(credentials.user.id, credentials.session.id, oldest) if (gid === null) return null return { ...credentials, @@ -300,7 +288,7 @@ class AuthzBase { } if (action === 'read') { - return await this.isInGroupTree(credentials.group.id, record.target_gid) + return (await this.isInGroupTree(credentials.group.id, record.target_gid)) ? allow() : deny(`No Access Allowed to that permission (${permissionId})`) } @@ -309,12 +297,12 @@ class AuthzBase { if (record.uid === credentials.user.id) { return deny(`Not allowed to modify your own permissions`) } - if (!await this.isActiveObject('user', record.uid)) { + if (!(await this.isActiveObject('user', record.uid))) { return deny(`Cannot modify permissions for a deleted user`) } return this.checkPermission(credentials, 'user', 'write', record.uid) } - if (!await this.isActiveGroup(record.gid)) { + if (!(await this.isActiveGroup(record.gid))) { return deny(`Cannot modify permissions for a deleted group`) } return this.checkPermission(credentials, 'group', 'write', record.gid) @@ -326,7 +314,7 @@ class AuthzBase { if (uid === credentials.user.id) { return deny(`Not allowed to modify your own permissions`) } - if (!await this.isActiveObject('user', uid)) { + if (!(await this.isActiveObject('user', uid))) { return deny(`Cannot create permissions for a deleted user`) } // the row is stored with both ids; an unrelated gid would put it outside @@ -343,7 +331,7 @@ class AuthzBase { const gid = payload.group?.id if (gid === undefined || gid === null) return deny(`No permission target found`) - if (!await this.isActiveGroup(gid)) { + if (!(await this.isActiveGroup(gid))) { return deny(`Cannot create permissions for a deleted group`) } return this.checkPermission(credentials, 'group', 'write', gid) diff --git a/lib/authz/store/file.js b/lib/authz/store/file.js index 20d1b7c..81a58d6 100644 --- a/lib/authz/store/file.js +++ b/lib/authz/store/file.js @@ -76,7 +76,7 @@ class AuthzRepoFile extends AuthzBase { const delegations = await Delegation.getDelegates(objectId, type, groupId) if (delegations.length === 0) return null - if (!await this.isActiveObject(resource, objectId)) return null + if (!(await this.isActiveObject(resource, objectId))) return null const row = delegations[0] // mysql returns raw nt_delegate columns here; shape them to match return { @@ -92,9 +92,7 @@ class AuthzRepoFile extends AuthzBase { } async getDelegatedZoneIds(groupIds) { - const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]) - .map(Number) - .filter(Number.isInteger) + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]).map(Number).filter(Number.isInteger) const zones = await this._rows('zone') const records = await this._rows('zone_record') diff --git a/lib/authz/store/mysql.js b/lib/authz/store/mysql.js index 1c40a9b..117ff5c 100644 --- a/lib/authz/store/mysql.js +++ b/lib/authz/store/mysql.js @@ -64,10 +64,9 @@ class AuthzRepoMysql extends AuthzBase { } async isActiveGroup(groupId) { - const rows = await Mysql.execute( - 'SELECT 1 FROM nt_group WHERE nt_group_id = ? AND deleted = 0', - [groupId], - ) + const rows = await Mysql.execute('SELECT 1 FROM nt_group WHERE nt_group_id = ? AND deleted = 0', [ + groupId, + ]) return rows.length > 0 } @@ -75,10 +74,9 @@ class AuthzRepoMysql extends AuthzBase { const table = delegateTable(resource) const idColumn = delegateIdColumn(resource)?.slice(2) if (!table || !idColumn) return false - const rows = await Mysql.execute( - `SELECT 1 FROM ${table} WHERE ${idColumn} = ? AND deleted = 0`, - [objectId], - ) + const rows = await Mysql.execute(`SELECT 1 FROM ${table} WHERE ${idColumn} = ? AND deleted = 0`, [ + objectId, + ]) return rows.length > 0 } @@ -96,9 +94,7 @@ class AuthzRepoMysql extends AuthzBase { } async getDelegatedZoneIds(groupIds) { - const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]) - .map(Number) - .filter(Number.isInteger) + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]).map(Number).filter(Number.isInteger) if (gids.length === 0) return [] const placeholders = gids.map(() => '?').join(', ') const rows = await Mysql.execute( diff --git a/lib/delegation/store/mysql.js b/lib/delegation/store/mysql.js index 25c519d..025b279 100644 --- a/lib/delegation/store/mysql.js +++ b/lib/delegation/store/mysql.js @@ -28,9 +28,7 @@ class DelegationRepoMysql extends DelegationBase { // nt_delegate has no unique key, so the check and insert must not // interleave; the lock goes with the connection when the transaction ends const result = await Mysql.transaction(async (tx) => { - const [lock] = await tx.execute('SELECT GET_LOCK(?, 10) AS acquired', [ - `ntd:${type}:${oid}:${gid}`, - ]) + const [lock] = await tx.execute('SELECT GET_LOCK(?, 10) AS acquired', [`ntd:${type}:${oid}:${gid}`]) if (lock.acquired !== 1) throw new Error(`Could not lock delegation ${type} ${oid} for group ${gid}`) const existing = await tx.execute( diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index d299d0c..73dd1f9 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -163,9 +163,7 @@ class Group extends GroupBase { if (Object.keys(args).length === 0) return true - const update = () => Mysql.update( - `nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap), - ) + const update = () => Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)) if (args.parent_gid === undefined) { const r = await Mysql.execute(...update()) diff --git a/lib/page.js b/lib/page.js index 302e9bf..9a6ed2d 100644 --- a/lib/page.js +++ b/lib/page.js @@ -6,8 +6,7 @@ const DEFAULT_MAX = 1000 export async function pageLimit(requested, fallback = DEFAULT_MAX) { const cfg = await Config.get('http') - const max = Number.isInteger(cfg.list_limit_max) && cfg.list_limit_max > 0 - ? cfg.list_limit_max - : DEFAULT_MAX + const max = + Number.isInteger(cfg.list_limit_max) && cfg.list_limit_max > 0 ? cfg.list_limit_max : DEFAULT_MAX return Math.min(Math.max(1, Number.isInteger(requested) ? requested : fallback), max) } diff --git a/lib/permission/store/file.js b/lib/permission/store/file.js index 97807e2..c6e2612 100644 --- a/lib/permission/store/file.js +++ b/lib/permission/store/file.js @@ -59,10 +59,12 @@ class PermissionRepoFile extends PermissionBase { this._loadGroups(), this._loadStandalone(), ]) - return [...users, ...groups] - .map((row) => row.permissions?.id) - .concat(standalone.map((row) => row.id)) - .reduce((max, id) => Math.max(max, id ?? 0), 0) + 1 + return ( + [...users, ...groups] + .map((row) => row.permissions?.id) + .concat(standalone.map((row) => row.id)) + .reduce((max, id) => Math.max(max, id ?? 0), 0) + 1 + ) } // --------------------------------------------------------------------------- @@ -104,7 +106,7 @@ class PermissionRepoFile extends PermissionBase { const current = users[idx].permissions if (!current || current.deleted) { const perm = deepMerge(permissionDefaults(uid, gid ?? users[idx].gid), args) - perm.id = current?.id ?? args.id ?? await this._nextId() + perm.id = current?.id ?? args.id ?? (await this._nextId()) perm.user.id = uid perm.group.id = gid ?? users[idx].gid perm.deleted = false @@ -126,7 +128,7 @@ class PermissionRepoFile extends PermissionBase { const current = groups[idx].permissions if (!current || current.deleted) { const perm = deepMerge(permissionDefaults(null, gid), args) - perm.id = current?.id ?? args.id ?? await this._nextId() + perm.id = current?.id ?? args.id ?? (await this._nextId()) perm.group.id = gid perm.deleted = false groups[idx].permissions = perm diff --git a/lib/permission/store/mysql.js b/lib/permission/store/mysql.js index 9919333..9c99960 100644 --- a/lib/permission/store/mysql.js +++ b/lib/permission/store/mysql.js @@ -11,11 +11,23 @@ const permDbMap = { } const permissionColumns = [ - 'group_write', 'group_create', 'group_delete', - 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', - 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', - 'user_write', 'user_create', 'user_delete', - 'nameserver_write', 'nameserver_create', 'nameserver_delete', + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', ] class PermissionRepoMySQL extends PermissionBase { diff --git a/lib/permission/test/index.js b/lib/permission/test/index.js index a7c8555..07bc133 100644 --- a/lib/permission/test/index.js +++ b/lib/permission/test/index.js @@ -57,20 +57,24 @@ describe('permission', function () { }) it('changes a permission', async () => { - assert.ok(await Permission.put({ - id: permTestCase.id, - name: 'Changed', - group_write: 1, - })) + assert.ok( + await Permission.put({ + id: permTestCase.id, + name: 'Changed', + group_write: 1, + }), + ) const perm = await Permission.get({ id: permTestCase.id }) assert.deepEqual(perm.name, 'Changed') assert.equal(perm.group.write, true) assert.equal(perm.group_write, undefined) - assert.ok(await Permission.put({ - id: permTestCase.id, - name: 'Test Permission', - group_write: 0, - })) + assert.ok( + await Permission.put({ + id: permTestCase.id, + name: 'Test Permission', + group_write: 0, + }), + ) }) it('finds a group permission by its id', async () => { diff --git a/lib/session/store/file.js b/lib/session/store/file.js index f713fc2..48a9379 100644 --- a/lib/session/store/file.js +++ b/lib/session/store/file.js @@ -5,7 +5,9 @@ let sessionWriteQueue = Promise.resolve() async function withSessionWriteLock(fn) { const previous = sessionWriteQueue let release - sessionWriteQueue = new Promise((resolve) => { release = resolve }) + sessionWriteQueue = new Promise((resolve) => { + release = resolve + }) await previous try { return await fn() diff --git a/lib/store-access.test.js b/lib/store-access.test.js index eeb77fb..2856b24 100644 --- a/lib/store-access.test.js +++ b/lib/store-access.test.js @@ -8,8 +8,7 @@ import { fileURLToPath } from 'node:url' // may talk to the mysql wrapper; this walks the source tree and // fails on any other module that reaches for it. const ROOTS = ['lib', 'routes'] -const SKIP_PATH = (p) => - p.split(path.sep).includes('store') || p.endsWith('.test.js') +const SKIP_PATH = (p) => p.split(path.sep).includes('store') || p.endsWith('.test.js') function sourceFiles(dir) { // this test lives in lib/, so the package root is one level up @@ -21,8 +20,7 @@ function sourceFiles(dir) { } function importSpecifiers(source) { - return [...source.matchAll(/(?:from|import)\s*\(?['"]([^'"]+)['"]/g)] - .map((m) => m[1]) + return [...source.matchAll(/(?:from|import)\s*\(?['"]([^'"]+)['"]/g)].map((m) => m[1]) } describe('store access', () => { @@ -31,9 +29,9 @@ describe('store access', () => { for (const dir of ROOTS) { for (const file of sourceFiles(dir)) { if (file.endsWith(`${path.sep}mysql.js`)) continue - const hits = importSpecifiers(readFileSync(file, 'utf8')) - .filter((spec) => (spec === 'mysql2' || spec.endsWith('/mysql.js')) - && !spec.includes('store/')) // dispatchers pick a backend from store/ + const hits = importSpecifiers(readFileSync(file, 'utf8')).filter( + (spec) => (spec === 'mysql2' || spec.endsWith('/mysql.js')) && !spec.includes('store/'), + ) // dispatchers pick a backend from store/ if (hits.length > 0) offenders.push(`${file}: ${hits.join(', ')}`) } } diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index 7bb4341..ea94d85 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -157,9 +157,7 @@ class ZoneRepoMySQL extends ZoneBase { ) let [finalQuery, finalParams] = applyZoneFilters(query, params, filters) - ;[finalQuery, finalParams] = applyAccessScope( - finalQuery, finalParams, gidScope, accessibleIds, - ) + ;[finalQuery, finalParams] = applyAccessScope(finalQuery, finalParams, gidScope, accessibleIds) finalQuery += ` ORDER BY ${sortBy} ${sortDir}` const rows = await Mysql.execute(`${finalQuery}${sqlLimit}`, finalParams) @@ -218,9 +216,7 @@ class ZoneRepoMySQL extends ZoneBase { ) let [finalQuery, finalParams] = applyZoneFilters(query, params, filters) - ;[finalQuery, finalParams] = applyAccessScope( - finalQuery, finalParams, gidScope, accessibleIds, - ) + ;[finalQuery, finalParams] = applyAccessScope(finalQuery, finalParams, gidScope, accessibleIds) const rows = await Mysql.execute(finalQuery, finalParams) return rows?.[0]?.total ?? 0 } @@ -240,10 +236,7 @@ class ZoneRepoMySQL extends ZoneBase { } if (args.deleted === false) { - const rows = await Mysql.execute( - 'SELECT zone FROM nt_zone WHERE nt_zone_id = ? LIMIT 1', - [id], - ) + const rows = await Mysql.execute('SELECT zone FROM nt_zone WHERE nt_zone_id = ? LIMIT 1', [id]) if (rows.length === 0) return false return withZoneNameLock(rows[0].zone, async (tx) => { await assertZoneNameAvailable(tx, rows[0].zone, id) @@ -298,10 +291,10 @@ class ZoneRepoMySQL extends ZoneBase { async setNameservers(zid, ids, db = Mysql) { await db.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [zid]) for (const nid of new Set(ids.map(Number))) { - await db.execute( - 'INSERT INTO nt_zone_nameserver (nt_zone_id, nt_nameserver_id) VALUES (?, ?)', - [zid, nid], - ) + await db.execute('INSERT INTO nt_zone_nameserver (nt_zone_id, nt_nameserver_id) VALUES (?, ?)', [ + zid, + nid, + ]) } return true } diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index 08f461a..3d2acf7 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -36,10 +36,7 @@ function applyIdScope(query, params, ids) { const connector = /\bWHERE\b/.test(query) ? ' AND' : ' WHERE' if (ids.length === 0) return [`${query}${connector} 1 = 0`, params] const placeholders = ids.map(() => '?').join(', ') - return [ - `${query}${connector} nt_zone_record_id IN (${placeholders})`, - [...params, ...ids], - ] + return [`${query}${connector} nt_zone_record_id IN (${placeholders})`, [...params, ...ids]] } class ZoneRecordMySQL extends ZoneRecordBase { diff --git a/lib/zone_record/test/index.js b/lib/zone_record/test/index.js index ae13a17..30099d7 100644 --- a/lib/zone_record/test/index.js +++ b/lib/zone_record/test/index.js @@ -46,12 +46,14 @@ describe('zone_record', function () { type: 'A', address: '192.0.2.1', }) - assert.ok(await ZoneRecord.put({ - id, - type: 'MX', - exchange: 'mail.example.com.', - preference: 10, - })) + assert.ok( + await ZoneRecord.put({ + id, + type: 'MX', + exchange: 'mail.example.com.', + preference: 10, + }), + ) const [updated] = await ZoneRecord.get({ id }) assert.equal(updated.type, 'MX') assert.equal(updated.exchange, 'mail.example.com.') diff --git a/routes/delegation.js b/routes/delegation.js index 49b2183..4c77c75 100644 --- a/routes/delegation.js +++ b/routes/delegation.js @@ -28,10 +28,7 @@ function capDelegationPerms(payload, perm, sourceDelegation, mode) { if (!capMap) return for (const [field, [resource, action]] of Object.entries(capMap)) { if (payload[field] === undefined) continue - if ( - perm[resource]?.[action] !== true - || (sourceDelegation && sourceDelegation[field] !== 1) - ) { + if (perm[resource]?.[action] !== true || (sourceDelegation && sourceDelegation[field] !== 1)) { if (mode === 'create') payload[field] = false else delete payload[field] } @@ -233,14 +230,10 @@ async function sourceDelegationFor(request) { const resource = DELEGABLE_RESOURCE[request.payload.type] if (!resource) return null const gid = await Authz.getObjectGroupId(resource, request.payload.oid) - if (gid !== null && await Authz.isInGroupTree(request.auth.credentials.group.id, gid)) { + if (gid !== null && (await Authz.isInGroupTree(request.auth.credentials.group.id, gid))) { return null } - return Authz.getDelegateAccess( - request.auth.credentials.group.id, - request.payload.oid, - resource, - ) + return Authz.getDelegateAccess(request.auth.credentials.group.id, request.payload.oid, resource) } export default DelegationRoutes diff --git a/routes/log.js b/routes/log.js index 447b026..36b81c3 100644 --- a/routes/log.js +++ b/routes/log.js @@ -36,9 +36,7 @@ function LogRoutes(server) { }, handler: async (request, h) => { // a record-only delegation reads the zone but not every record in it - const ids = await Authz.getZoneRecordReadScope( - request.auth.credentials.group.id, request.query.zid, - ) + const ids = await Authz.getZoneRecordReadScope(request.auth.credentials.group.id, request.query.zid) const args = ids === null ? request.query : { ...request.query, ids } return logResponse(h, await Audit.listZoneRecords(args)) }, diff --git a/routes/permission.js b/routes/permission.js index 0db40e5..ffd1885 100644 --- a/routes/permission.js +++ b/routes/permission.js @@ -61,9 +61,7 @@ function PermissionRoutes(server) { const currentPerm = uid === undefined ? null : await Permission.getEffective(uid) request.payload = Authz.capPermissions(userPerm, request.payload, currentPerm) if (uid !== undefined && request.payload.inherit !== true) { - request.payload = Authz.preserveUnmanagedPermissions( - userPerm, request.payload, currentPerm, - ) + request.payload = Authz.preserveUnmanagedPermissions(userPerm, request.payload, currentPerm) } delete request.payload.id const pid = await Permission.create(request.payload) @@ -98,9 +96,7 @@ function PermissionRoutes(server) { const id = parseInt(request.params.id, 10) const existing = await Permission.get({ id }) if (!existing) { - return h - .response({ meta: { api: meta.api, msg: `permission not found` } }) - .code(404) + return h.response({ meta: { api: meta.api, msg: `permission not found` } }).code(404) } const userPerm = await Permission.getEffective(request.auth.credentials.user.id) diff --git a/routes/zone.js b/routes/zone.js index 1a9b63d..bf61841 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -46,9 +46,7 @@ async function unusableNameservers(request) { } function nameserversNotUsable(h, ids) { - return h - .response({ meta: { api: meta.api, msg: `nameserver(s) not usable: ${ids.join(', ')}` } }) - .code(403) + return h.response({ meta: { api: meta.api, msg: `nameserver(s) not usable: ${ids.join(', ')}` } }).code(403) } // single-zone responses carry the assignment; lists stay one query diff --git a/routes/zone_record.js b/routes/zone_record.js index 265c7bd..5a001cd 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -67,9 +67,7 @@ function ZoneRecordRoutes(server) { if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir if (!getArgs.id && getArgs.zid) { - const ids = await Authz.getZoneRecordReadScope( - request.auth.credentials.group.id, getArgs.zid, - ) + const ids = await Authz.getZoneRecordReadScope(request.auth.credentials.group.id, getArgs.zid) if (ids !== null) getArgs.ids = ids } @@ -126,12 +124,7 @@ function ZoneRecordRoutes(server) { const zrs = await ZoneRecord.get({ id }) const zones = await Zone.get({ id: zrs[0].zid }) - await Audit.logZoneRecord( - request.auth.credentials.user, - 'added', - zrs[0], - zones[0], - ) + await Audit.logZoneRecord(request.auth.credentials.user, 'added', zrs[0], zones[0]) return h .response({ @@ -233,12 +226,7 @@ function ZoneRecordRoutes(server) { id: zrs[0].id, deleted: 1, }) - await Audit.logZoneRecord( - request.auth.credentials.user, - 'deleted', - zrs[0], - zones[0], - ) + await Audit.logZoneRecord(request.auth.credentials.user, 'deleted', zrs[0], zones[0]) const deletedZrs = await ZoneRecord.get({ id: zrs[0].id, From 0229a7e325af1103de61b4dd5a39bf1939c8b194 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:26:17 +0100 Subject: [PATCH 41/48] authz: accept a 0 is_admin on a v2 schema A database created by 2.x defaults nt_user.is_admin to 0, so the stripped field reads back 0 there and null on a v3 install. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RxcUNWRPrY7gKukije8hFC --- routes/authz.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/authz.test.js b/routes/authz.test.js index c9c368a..3cc69e5 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -568,7 +568,7 @@ describe('authz plugin - user self-ops', () => { assert.equal(res.statusCode, 201) const [stored] = await Mysql.execute('SELECT is_admin FROM nt_user WHERE nt_user_id = ?', [U_CREATED.id]) - assert.equal(stored.is_admin, null) + assert.ok(!stored.is_admin) // v2 schemas default the column to 0, v3 to null }) }) From b516a4f78626e265ead2a9ce7acd3e055003cddb Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:53:54 +0100 Subject: [PATCH 42/48] authz: align file-store permission values --- lib/authz/store/file.js | 1 + lib/permission/store/file.js | 17 ++++++++++++++++- routes/authz.test.js | 20 +++++++++----------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/authz/store/file.js b/lib/authz/store/file.js index 81a58d6..652045e 100644 --- a/lib/authz/store/file.js +++ b/lib/authz/store/file.js @@ -35,6 +35,7 @@ class AuthzRepoFile extends AuthzBase { if (!row) return null if (resource === 'group') { // the root group has no parent; v2 routes its objects to group 1 + if (row.parent_gid === 0) return 1 return row.parent_gid ?? row.gid ?? 1 } return row.gid ?? null diff --git a/lib/permission/store/file.js b/lib/permission/store/file.js index c6e2612..5163333 100644 --- a/lib/permission/store/file.js +++ b/lib/permission/store/file.js @@ -77,7 +77,7 @@ class PermissionRepoFile extends PermissionBase { // uid/gid are internal storage hints; never expose them in the response delete r.uid delete r.gid - r.deleted = Boolean(r.deleted) + for (const field of ['inherit', 'self_write', 'deleted']) r[field] = Boolean(r[field]) if (r.user && r.user.id === undefined) r.user.id = null if (r.group && r.group.id === undefined) r.group.id = null if (r.nameserver && !Array.isArray(r.nameserver.usable)) r.nameserver.usable = [] @@ -338,6 +338,21 @@ function deepMerge(target, source) { } function expandFlatPermissions(permission) { + for (const field of ['inherit', 'self_write', 'deleted']) { + if (permission[field] !== undefined) + permission[field] = permission[field] === true || permission[field] === 1 + } + + if (permission.usable_ns !== undefined) { + const usable = permission.usable_ns + let values = [] + if (Array.isArray(usable)) values = usable.map(String) + else if (![undefined, null, ''].includes(usable)) values = String(usable).split(',') + permission.nameserver ??= {} + permission.nameserver.usable = values + delete permission.usable_ns + } + for (const resource of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { for (const action of ['create', 'write', 'delete', 'delegate']) { const field = `${resource}_${action}` diff --git a/routes/authz.test.js b/routes/authz.test.js index 3cc69e5..a8f6f9c 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -125,10 +125,11 @@ const NS = { id: 4200, gid: 4200, name: 'ns1.authz.example.com.', + type: 'bind', ttl: 3600, description: 'authz test ns', address: '192.0.2.10', - export: { type: 'bind', interval: 0, serials: 0 }, + export: { interval: 0, serials: 0 }, } let server @@ -515,7 +516,7 @@ describe('authz plugin - user self-ops', () => { }) it('does not pass is_admin through self-write', async () => { - const [before] = await Mysql.execute('SELECT is_admin FROM nt_user WHERE nt_user_id = ?', [U_FULL.id]) + const [before] = await User.get({ id: U_FULL.id }) const res = await server.inject({ method: 'PUT', url: `/user/${U_FULL.id}`, @@ -528,12 +529,8 @@ describe('authz plugin - user self-ops', () => { assert.equal(res.statusCode, 200) const [user] = await User.get({ id: U_FULL.id }) - const [stored] = await Mysql.execute( - 'SELECT nt_group_id AS gid, is_admin FROM nt_user WHERE nt_user_id = ?', - [U_FULL.id], - ) - assert.equal(stored.gid, G_ROOT.id) - assert.equal(stored.is_admin, before.is_admin) + assert.equal(user.gid, G_ROOT.id) + assert.equal(user.is_admin, before.is_admin) assert.equal(user.first_name, 'Still Full') }) @@ -567,8 +564,8 @@ describe('authz plugin - user self-ops', () => { }) assert.equal(res.statusCode, 201) - const [stored] = await Mysql.execute('SELECT is_admin FROM nt_user WHERE nt_user_id = ?', [U_CREATED.id]) - assert.ok(!stored.is_admin) // v2 schemas default the column to 0, v3 to null + const [stored] = await User.get({ id: U_CREATED.id }) + assert.equal(stored.is_admin, false) }) }) @@ -944,9 +941,10 @@ describe('authz plugin - nameserver reads', () => { id: 4201, gid: G_CHILD.id, name: 'ns2.authz.example.com.', + type: 'bind', ttl: 3600, address: '192.0.2.11', - export: { type: 'bind', interval: 0, serials: 0 }, + export: { interval: 0, serials: 0 }, } before(async () => { From 91af63c20b5297ce39b8bf730e3cecd17cf37ea1 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:00:07 +0100 Subject: [PATCH 43/48] test: cover the delegation route lifecycle --- routes/authz.test.js | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/routes/authz.test.js b/routes/authz.test.js index a8f6f9c..b323f14 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -798,6 +798,53 @@ describe('authz plugin - delegation routes', () => { assert.equal(res.result.delegation[0].nt_group_id, G_CHILD.id) }) + it('returns 409 without creating a duplicate delegation', async () => { + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: { gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE' }, + }) + assert.equal(res.statusCode, 409) + assert.equal((await Delegation.get({ gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE' })).length, 1) + }) + + it('updates an existing delegation', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/delegation', + headers: authFull.headers, + payload: { gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE', perm_write: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.delegation[0].delegate_write, 1) + }) + + it('returns 404 when updating a missing delegation', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/delegation', + headers: authFull.headers, + payload: { + gid: G_CHILD.id, + oid: ZR_INTREE_OTHER.id, + type: 'ZONERECORD', + perm_write: true, + }, + }) + assert.equal(res.statusCode, 404) + }) + + it('deletes an existing delegation and returns 404 when repeated', async () => { + const url = `/delegation?gid=${G_CHILD.id}&oid=${Z_INTREE.id}&type=ZONE` + let res = await server.inject({ method: 'DELETE', url, headers: authFull.headers }) + assert.equal(res.statusCode, 200) + assert.equal((await Delegation.get({ gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE' })).length, 0) + + res = await server.inject({ method: 'DELETE', url, headers: authFull.headers }) + assert.equal(res.statusCode, 404) + }) + it( 'creates one delegation when identical requests race', { From 9ffa2890897c25cdc5fa0db606cd629a916284b6 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:18:43 +0100 Subject: [PATCH 44/48] test: race delegation creates on every store #66's discover_tests put routes/authz.test.js in the json and toml suites, where the mysql-only skip failed the metarepo's no-skips gate. The file store serializes writes, so the race holds there too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GiMQBtdjyPFutmvYZpZMy7 --- routes/authz.test.js | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/routes/authz.test.js b/routes/authz.test.js index b323f14..8b92daa 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -845,22 +845,16 @@ describe('authz plugin - delegation routes', () => { assert.equal(res.statusCode, 404) }) - it( - 'creates one delegation when identical requests race', - { - skip: (process.env.NICTOOL_DATA_STORE ?? 'mysql') !== 'mysql', - }, - async () => { - const args = { gid: G_CHILD.id, oid: ZR_INTREE_OTHER.id, type: 'ZONERECORD' } - await Delegation.delete(args) - const results = await Promise.all([1, 2, 3].map(() => Delegation.create(args))) - assert.equal(results.filter((r) => r.created).length, 1) - assert.equal(results.filter((r) => r.duplicate).length, 2) - const rows = await Delegation.get(args) - assert.equal(rows.length, 1) - await Delegation.delete(args) - }, - ) + it('creates one delegation when identical requests race', async () => { + const args = { gid: G_CHILD.id, oid: ZR_INTREE_OTHER.id, type: 'ZONERECORD' } + await Delegation.delete(args) + const results = await Promise.all([1, 2, 3].map(() => Delegation.create(args))) + assert.equal(results.filter((r) => r.created).length, 1) + assert.equal(results.filter((r) => r.duplicate).length, 2) + const rows = await Delegation.get(args) + assert.equal(rows.length, 1) + await Delegation.delete(args) + }) it('cannot delegate an object back to your own group', async () => { const res = await server.inject({ From e4bd365ed72a94ab2a1d7ae2213dbdde302109d0 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:14:44 +0100 Subject: [PATCH 45/48] test: cover authz mutation boundaries --- routes/authz.test.js | 495 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) diff --git a/routes/authz.test.js b/routes/authz.test.js index 8b92daa..9c4b151 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -1297,3 +1297,498 @@ describe('authz plugin - self permission inheritance', () => { assert.equal(after.zone.write, before.zone.write) }) }) + +describe('authz plugin - permission administration', () => { + const U_TARGET = { + id: 4220, + gid: G_CHILD.id, + username: 'authz-admin-target', + email: 'authz-admin-target@example.com', + password: PASSWORD, + first_name: 'Admin', + last_name: 'Target', + inherit_group_permissions: false, + } + const U_GONE = { + id: 4221, + gid: G_CHILD.id, + username: 'authz-gone', + email: 'authz-gone@example.com', + password: PASSWORD, + first_name: 'Gone', + last_name: 'User', + inherit_group_permissions: false, + } + const G_GONE = { id: 4220, parent_gid: G_ROOT.id, name: 'authz-gone' } + + const clear = async () => { + for (const u of [U_TARGET, U_GONE]) { + for (const deleted of [false, true]) { + const p = await Permission.get({ uid: u.id, deleted }) + if (p) await Permission.destroy({ id: p.id }) + } + await User.destroy({ id: u.id }) + } + await Group.destroy({ id: G_GONE.id }) + } + + before(async () => { + await clear() + await Group.create(G_GONE) + for (const u of [U_TARGET, U_GONE]) await User.create(u) + }) + + after(clear) + + it('denies a permission id that does not exist', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/permission/999999', + headers: authFull.headers, + payload: { self_write: true }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /No Access Allowed to that permission/) + }) + + it('caps usable nameservers to those the actor holds', async () => { + const perm = await Permission.get({ gid: G_CHILD.id }) + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { nameserver: { usable: [4200, 4201] } }, + }) + assert.equal(res.statusCode, 200) + const after = await Permission.get({ id: perm.id }) + assert.deepEqual(after.nameserver.usable.map(String), ['4200']) + await Permission.put({ id: perm.id, nameserver: { usable: [] } }) + }) + + it('ignores inherit on a group permission', async () => { + const perm = await Permission.get({ gid: G_CHILD.id }) + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { inherit: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await Permission.get({ id: perm.id })).inherit, false) + }) + + it('does not grant self_write when the actor lacks user write', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + const perm = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: actor.id, user_write: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { self_write: true, zone: { create: true } }, + }) + assert.equal(res.statusCode, 200) + const after = await Permission.get({ id: perm.id }) + assert.equal(after.self_write, false) + assert.equal(after.zone.create, true) + } finally { + await Permission.put({ id: actor.id, user_write: true }) + await Permission.put({ id: perm.id, zone_create: false }) + } + }) + + it('keeps a user explicit when inheriting would grant more than the actor holds', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const group = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: group.id, nameserver: { usable: [4201] } }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { inherit: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await Permission.get({ id: perm.id })).inherit, false) + } finally { + await Permission.put({ id: group.id, nameserver: { usable: [] } }) + } + }) + + it('switches a user to inherited permissions the actor can grant', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { inherit: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.permission.inherit, true) + const group = await Permission.get({ gid: G_CHILD.id }) + assert.equal((await Permission.getEffective(U_TARGET.id)).id, group.id) + await Permission.put({ id: perm.id, inherit: false }) + }) + + it('a limited actor cannot delete another user permissions', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const res = await server.inject({ + method: 'DELETE', + url: `/permission/${perm.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + assert.ok(await Permission.get({ id: perm.id })) + }) + + it('deletes another user permissions, which fall back to the group', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const res = await server.inject({ + method: 'DELETE', + url: `/permission/${perm.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.permission.id, perm.id) + assert.equal(await Permission.get({ id: perm.id }), undefined) + const group = await Permission.get({ gid: G_CHILD.id }) + assert.equal((await Permission.getEffective(U_TARGET.id)).id, group.id) + }) + + it('cannot modify permissions for a deleted user', async () => { + const perm = await Permission.get({ uid: U_GONE.id }) + await User.delete({ id: U_GONE.id }) + const put = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { self_write: true }, + }) + assert.equal(put.statusCode, 403) + assert.match(put.result.error_msg, /deleted user/) + + const post = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { name: 'gone', user: { id: U_GONE.id } }, + }) + assert.equal(post.statusCode, 403) + assert.match(post.result.error_msg, /deleted user/) + assert.equal((await Permission.get({ id: perm.id })).self_write, false) + }) + + it('cannot modify permissions for a deleted group, or create in it', async () => { + const perm = await Permission.get({ gid: G_GONE.id }) + await Group.delete({ id: G_GONE.id }) + const put = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { self_write: true }, + }) + assert.equal(put.statusCode, 403) + assert.match(put.result.error_msg, /deleted group/) + + const post = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { name: 'gone', group: { id: G_GONE.id } }, + }) + assert.equal(post.statusCode, 403) + assert.match(post.result.error_msg, /deleted group/) + + const zone = await server.inject({ + method: 'POST', + url: '/zone', + headers: authFull.headers, + payload: { ...Z_INTREE, id: 4220, gid: G_GONE.id, zone: 'gone.authz.example.com.' }, + }) + assert.equal(zone.statusCode, 403) + assert.match(zone.result.error_msg, /No active target group/) + assert.equal((await Zone.get({ id: 4220 })).length, 0) + }) + + it('refuses a permission with no target', async () => { + const res = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { name: 'nowhere' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /No permission target/) + }) +}) + +describe('authz plugin - user permission transitions', () => { + const U_MOVED = { + id: 4222, + gid: G_CHILD.id, + username: 'authz-moved', + email: 'authz-moved@example.com', + password: PASSWORD, + first_name: 'Moved', + last_name: 'User', + inherit_group_permissions: true, + } + + const U_GRANTED = { + id: 4223, + gid: G_CHILD.id, + username: 'authz-granted', + email: 'authz-granted@example.com', + password: PASSWORD, + first_name: 'Granted', + last_name: 'User', + inherit_group_permissions: false, + } + + const clear = async () => { + for (const u of [U_MOVED, U_GRANTED]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + } + + before(async () => { + await clear() + await User.create(U_MOVED) + }) + + after(clear) + + it('a limited actor cannot change another user permissions', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authLimited.headers, + payload: { zone_write: true }, + }) + assert.equal(res.statusCode, 403) + assert.equal(await Permission.get({ uid: U_MOVED.id }), undefined) + }) + + it('grants only what the actor holds when a field makes a user explicit', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + const group = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + await Permission.put({ id: group.id, zone_delete: true }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { zone_write: true, zone_delete: false }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.user[0].inherit_group_permissions, false) + + const explicit = await Permission.get({ uid: U_MOVED.id }) + assert.equal(explicit.inherit, false) + const effective = await Permission.getEffective(U_MOVED.id) + assert.equal(effective.id, explicit.id) + assert.equal(effective.zone.write, true) + assert.equal(effective.zone.delete, true) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + await Permission.put({ id: group.id, zone_delete: false }) + } + }) + + it('switching a user back to inherited drops the explicit row', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(await Permission.get({ uid: U_MOVED.id }), undefined) + const group = await Permission.get({ gid: G_CHILD.id }) + assert.equal((await Permission.getEffective(U_MOVED.id)).id, group.id) + }) + + it('POST /user stores only the permissions the actor may grant', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'POST', + url: '/user', + headers: authFull.headers, + payload: { ...U_GRANTED, zone_write: true, zone_delete: true }, + }) + assert.equal(res.statusCode, 201) + const perm = await Permission.get({ uid: U_GRANTED.id }) + assert.equal(perm.inherit, false) + assert.equal(perm.zone.write, true) + assert.equal(perm.zone.delete, false) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + } + }) + + it('an explicit switch creates the row once, then edits it', async () => { + const first = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: false }, + }) + assert.equal(first.statusCode, 200) + const explicit = await Permission.get({ uid: U_MOVED.id }) + assert.equal(explicit.inherit, false) + assert.equal(explicit.zone.write, false) + + const second = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: false, zone_write: true }, + }) + assert.equal(second.statusCode, 200) + const edited = await Permission.get({ uid: U_MOVED.id }) + assert.equal(edited.id, explicit.id) + assert.equal(edited.zone.write, true) + }) +}) + +describe('authz plugin - group permission mutation', () => { + const G_MADE = { id: 4221, parent_gid: G_ROOT.id, name: 'authz-made' } + + before(async () => { + await Group.destroy({ id: G_MADE.id }) + }) + + after(async () => { + await Group.destroy({ id: G_MADE.id }) + }) + + it('POST /group stores only the permissions the actor may grant', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'POST', + url: '/group', + headers: authFull.headers, + payload: { ...G_MADE, zone_write: true, zone_delete: true, usable_ns: [4200, 4201] }, + }) + assert.equal(res.statusCode, 201) + const perm = await Permission.get({ gid: G_MADE.id }) + assert.equal(perm.zone.write, true) + assert.equal(perm.zone.delete, false) + assert.deepEqual(perm.nameserver.usable.map(String), ['4200']) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + } + }) + + it('PUT /group/{id} changes only the permissions the actor may grant', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_MADE.id}`, + headers: authFull.headers, + payload: { name: 'authz-remade', zone_delete: true, zonerecord_write: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.group[0].name, 'authz-remade') + const perm = await Permission.get({ gid: G_MADE.id }) + assert.equal(perm.zone.write, true) + assert.equal(perm.zone.delete, false) + assert.equal(perm.zonerecord.write, true) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + } + }) + + it('scopes the group collection to the caller tree', async () => { + const outside = await server.inject({ + method: 'GET', + url: `/group?parent_gid=${G_ROOT.id}`, + headers: authLimited.headers, + }) + assert.equal(outside.statusCode, 403) + + const own = await server.inject({ method: 'GET', url: '/group', headers: authLimited.headers }) + assert.equal(own.statusCode, 200) + assert.ok(own.result.group.every((g) => g.parent_gid === G_OUTSIDE.id)) + + const tree = await server.inject({ + method: 'GET', + url: `/group?parent_gid=${G_ROOT.id}&include_subgroups=true`, + headers: authFull.headers, + }) + assert.equal(tree.statusCode, 200) + const ids = tree.result.group.map((g) => g.id) + assert.ok(ids.includes(G_CHILD.id)) + assert.ok(ids.includes(G_MADE.id)) + }) +}) + +describe('authz plugin - delegated collection visibility', () => { + const Z_HELD = { + ...Z_INTREE, + id: 4220, + zone: 'held.authz.example.com.', + mailaddr: 'hostmaster.held.authz.example.com.', + } + const held = { gid: G_CHILD.id, oid: Z_HELD.id, type: 'ZONE' } + + const clear = async () => { + await Delegation.delete(held) + await Zone.destroy({ id: Z_HELD.id }) + } + + before(async () => { + await clear() + await Zone.create(Z_HELD) + }) + + after(clear) + + const listHeld = async () => { + const res = await server.inject({ + method: 'GET', + url: `/delegation?gid=${G_CHILD.id}&type=ZONE`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + return res.result.delegation.filter((d) => d.nt_object_id === Z_HELD.id) + } + + it('lists a delegated zone only while the zone and delegation live', async () => { + assert.deepEqual(await listHeld(), []) + + await Delegation.create({ ...held, perm_write: true, perm_delete: false }) + const [row] = await listHeld() + assert.equal(row.nt_group_id, G_CHILD.id) + assert.equal(row.group_name, G_CHILD.name) + assert.equal(row.nt_zone_id, Z_HELD.id) + assert.equal(row.delegate_write, 1) + assert.equal(row.delegate_delete, 0) + + await Delegation.delete(held) + assert.deepEqual(await listHeld(), []) + + await Delegation.create(held) + await Zone.delete({ id: Z_HELD.id }) + assert.deepEqual(await listHeld(), []) + + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: held, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /deleted object/) + }) +}) From fff43e543fa6d6115e7341061a1b34778e352851 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:07:21 +0100 Subject: [PATCH 46/48] test: drop the narration from file-stores --- lib/file-stores.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/file-stores.test.js b/lib/file-stores.test.js index 17b4b3b..340b928 100644 --- a/lib/file-stores.test.js +++ b/lib/file-stores.test.js @@ -5,9 +5,8 @@ import { after, before, describe, it } from 'node:test' import Config from './config.js' -// Exercises the json file backends directly. The mysql behavior of these -// subsystems is covered by the mysql-backend suite; this covers the parity -// story that keeps a file-store deployment working. +// The mysql side of these subsystems is covered by the mysql-backend suite; +// this is the file-store half. const saved = { storeType: process.env.NICTOOL_DATA_STORE, storePath: process.env.NICTOOL_DATA_STORE_PATH, From 5bc6d7c6f2bbb78c86fab0114e06054ea2b98c57 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:55:10 +0100 Subject: [PATCH 47/48] test: run authz on file stores --- lib/audit.test.js | 7 +++---- lib/audit/store/base.js | 5 +++++ lib/audit/store/file.js | 20 ++++++++++++++++++++ lib/audit/store/mysql.js | 9 +++++++++ lib/authz.test.js | 6 +----- lib/permission/store/file.js | 18 ++++++++++++++++++ routes/authz.test.js | 9 ++------- 7 files changed, 58 insertions(+), 16 deletions(-) diff --git a/lib/audit.test.js b/lib/audit.test.js index 08da7bc..26483b9 100644 --- a/lib/audit.test.js +++ b/lib/audit.test.js @@ -3,7 +3,6 @@ import { after, before, describe, it } from 'node:test' import Audit from './audit/index.js' import Group from './group/index.js' -import Mysql from './mysql.js' import User from './user/index.js' import Zone from './zone/index.js' import ZoneRecord from './zone_record/index.js' @@ -34,7 +33,7 @@ const record = { } before(async () => { - await Mysql.execute('DELETE FROM nt_user_global_log WHERE nt_user_id = ?', [actor.id]) + await Audit.destroyByUser(actor.id) await ZoneRecord.destroy({ id: zrid }) await Zone.destroy({ id: zid }) await User.destroy({ id: actor.id }) @@ -54,12 +53,12 @@ before(async () => { }) after(async () => { - await Mysql.execute('DELETE FROM nt_user_global_log WHERE nt_user_id = ?', [actor.id]) + await Audit.destroyByUser(actor.id) await ZoneRecord.destroy({ id: zrid }) await Zone.destroy({ id: zid }) await User.destroy({ id: actor.id }) await Group.destroy({ id: gid }) - await Mysql.disconnect() + await Group.disconnect() }) describe('audit log', () => { diff --git a/lib/audit/store/base.js b/lib/audit/store/base.js index b32c028..d2a5597 100644 --- a/lib/audit/store/base.js +++ b/lib/audit/store/base.js @@ -11,6 +11,7 @@ * listGlobal(args) → { rows, total, filtered, limit, offset } * listZones(args) → same shape * listZoneRecords(args) → same shape + * destroyByUser(uid) → boolean */ const actionDescription = { added: 'initial creation', @@ -109,6 +110,10 @@ class AuditBase { async listZoneRecords(_args) { throw new Error('listZoneRecords() not implemented by this store') } + + async destroyByUser(_uid) { + throw new Error('destroyByUser() not implemented by this store') + } } function compact(obj) { diff --git a/lib/audit/store/file.js b/lib/audit/store/file.js index ba6bdca..e731491 100644 --- a/lib/audit/store/file.js +++ b/lib/audit/store/file.js @@ -126,6 +126,26 @@ class AuditRepoFile extends AuditBase { }, }) } + + async destroyByUser(uid) { + const remove = (store, key) => + store.update(key, (rows) => { + let removed = false + for (let i = rows.length - 1; i >= 0; i -= 1) { + if (rows[i].uid === uid) { + rows.splice(i, 1) + removed = true + } + } + return removed + }) + const results = await Promise.all([ + remove(this.zoneLog, 'zone_log'), + remove(this.recordLog, 'record_log'), + remove(this.globalLog, 'global_log'), + ]) + return results.some(Boolean) + } } async function loadUsers() { diff --git a/lib/audit/store/mysql.js b/lib/audit/store/mysql.js index 4aace97..7032b0b 100644 --- a/lib/audit/store/mysql.js +++ b/lib/audit/store/mysql.js @@ -123,6 +123,15 @@ class AuditRepoMysql extends AuditBase { args, }) } + + async destroyByUser(uid) { + let removed = false + for (const table of ['nt_zone_log', 'nt_zone_record_log', 'nt_user_global_log']) { + const result = await Mysql.execute(...Mysql.delete(table, { nt_user_id: uid })) + removed ||= result.affectedRows > 0 + } + return removed + } } function mapZone(detail) { diff --git a/lib/authz.test.js b/lib/authz.test.js index ee75d6b..7e9469f 100644 --- a/lib/authz.test.js +++ b/lib/authz.test.js @@ -9,7 +9,6 @@ import Nameserver from './nameserver/index.js' import Permission from './permission/index.js' import Delegation from './delegation/index.js' import Authz from './authz/index.js' -import Mysql from './mysql.js' const G_ROOT = { id: 4200, @@ -146,7 +145,6 @@ before(async () => { await User.destroy({ id }) } for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) - await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) for (const u of [U_FULL, U_LIMITED, U_NOSELF]) await User.create(u) @@ -270,9 +268,7 @@ after(async () => { for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { await Group.destroy({ id: g.id }) } - // Clean up subgroup entries - await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) - await Mysql.disconnect() + await Group.disconnect() }) describe('checkPermission', () => { diff --git a/lib/permission/store/file.js b/lib/permission/store/file.js index 5163333..15e126e 100644 --- a/lib/permission/store/file.js +++ b/lib/permission/store/file.js @@ -287,6 +287,24 @@ class PermissionRepoFile extends PermissionBase { } async destroy(args) { + if (args.uid !== undefined) { + const users = await this._loadUsers() + const uidx = users.findIndex((u) => u.id === args.uid && u.permissions) + if (uidx === -1) return false + delete users[uidx].permissions + await this._saveUsers(users) + return true + } + + if (args.gid !== undefined) { + const groups = await this._loadGroups() + const gidx = groups.findIndex((g) => g.id === args.gid && g.permissions) + if (gidx === -1) return false + delete groups[gidx].permissions + await this._saveGroups(groups) + return true + } + if (!args.id) return false const users = await this._loadUsers() diff --git a/routes/authz.test.js b/routes/authz.test.js index 9c4b151..46fce77 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -10,7 +10,6 @@ import Nameserver from '../lib/nameserver/index.js' import Permission from '../lib/permission/index.js' import Audit from '../lib/audit/index.js' import Delegation from '../lib/delegation/index.js' -import Mysql from '../lib/mysql.js' const G_ROOT = { id: 4200, @@ -165,7 +164,6 @@ before(async () => { await User.destroy({ id }) } for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) - await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) for (const u of [U_FULL, U_LIMITED]) await User.create(u) @@ -295,8 +293,7 @@ after(async () => { for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { await Group.destroy({ id: g.id }) } - await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', [4200, 4201, 4202]) - await Mysql.disconnect() + await Group.disconnect() }) describe('authz plugin - zone routes', () => { @@ -934,7 +931,6 @@ describe('authz plugin - create target resolution', () => { after(async () => { await Group.destroy({ id: G_PLANTED }) - await Mysql.execute('DELETE FROM nt_group_subgroups WHERE nt_subgroup_id = ?', [G_PLANTED]) }) it('authorizes the group a new group is actually filed under', async () => { @@ -1069,8 +1065,7 @@ describe('authz plugin - permission records', () => { inherit_group_permissions: true, } - // direct SQL: Permission.get throws when a crashed run left two rows behind - const clearTarget = () => Mysql.execute('DELETE FROM nt_perm WHERE nt_user_id = ?', [U_TARGET.id]) + const clearTarget = () => Permission.destroy({ uid: U_TARGET.id }) before(async () => { await clearTarget() From 0260ff86a362b54c11594500be637a69bb82c4f8 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:46:56 +0100 Subject: [PATCH 48/48] cov: measure the json store too The mysql run never loads the file stores, so their branches count as uncovered and the project number lands under main's. The json store measures 93.17% over the files it exercises. Each store keeps its own lcov report and codecov receives both for the commit. The mysql report stays at coverage/lcov.info, where coveralls reads. --- .github/workflows/coverage.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 43670dc..beaa42d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -26,14 +26,19 @@ jobs: run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - name: Initialize MySQL run: sh sql/init-mysql.sh - - run: npm run test:coverage:lcov + - name: Measure each store env: NODE_ENV: cov + run: | + for store in json mysql; do + NICTOOL_DATA_STORE=$store npm run test:coverage:lcov + cp coverage/lcov.info coverage/lcov-$store.info + done - name: codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info + files: ./coverage/lcov-json.info,./coverage/lcov-mysql.info disable_search: true fail_ci_if_error: true - name: Coveralls