From ddc1f18fa7808193b797137cb7afdf80d72d76e6 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] add authz enforcement layer, port 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. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/authz-plugin.js | 92 ++++++ lib/authz.js | 175 +++++++++++ lib/authz.test.js | 543 +++++++++++++++++++++++++++++++++ lib/delegation.js | 203 ++++++++++++ lib/group/store/mysql.js | 4 +- lib/nameserver/store/mysql.js | 22 +- lib/permission/index.js | 13 +- lib/user/store/mysql.js | 15 +- lib/zone/store/mysql.js | 15 +- lib/zone_record/store/mysql.js | 29 +- routes/authz.test.js | 375 +++++++++++++++++++++++ routes/delegation.js | 216 +++++++++++++ routes/group.js | 60 +++- routes/index.js | 5 + routes/nameserver.js | 8 +- routes/permission.js | 32 ++ routes/session.js | 21 ++ routes/user.js | 83 ++++- routes/zone.js | 11 +- routes/zone_record.js | 9 +- 20 files changed, 1896 insertions(+), 35 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 diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js new file mode 100644 index 0000000..3b29e4d --- /dev/null +++ b/lib/authz-plugin.js @@ -0,0 +1,92 @@ +import Authz from './authz.js' +import Mysql from './mysql.js' + +const TYPE_TO_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', + NAMESERVER: 'nameserver', + GROUP: 'group', +} + +const authzPlugin = { + name: 'nt-authz', + register(server) { + server.ext('onPreHandler', async (request, h) => { + const permCfg = request.route.settings.app?.permission + if (!permCfg) return h.continue + + if (!request.auth.isAuthenticated) return h.continue + + let { resource, action } = permCfg + const { idFrom } = permCfg + const credentials = request.auth.credentials + + let objectId + if (idFrom) { + objectId = resolveId(request, idFrom) + if (objectId !== undefined) objectId = Number(objectId) + } + + // List requests (no objectId) don't need per-object authz + if (action === 'read' && objectId === undefined) { + return h.continue + } + + // Delegation: resolve resource from the type field + if (action === 'delegate') { + const type = request.payload?.type ?? request.query?.type + if (type && TYPE_TO_RESOURCE[type]) { + resource = TYPE_TO_RESOURCE[type] + } + } + + let opts + if (action === 'create') { + const targetGid = await resolveTargetGroup( + request, resource, + ) + if (targetGid) opts = { targetGroupId: targetGid } + } + + const result = await Authz.checkPermission( + credentials, resource, action, objectId, opts, + ) + + 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] +} + +async function resolveTargetGroup(request, resource) { + const gid = request.payload?.gid + ?? request.payload?.nt_group_id + ?? request.payload?.parent_gid + if (gid) return Number(gid) + + 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 = ?', + [zid], + ) + if (rows.length > 0) return rows[0].nt_group_id + } + } + + return null +} + +export default authzPlugin diff --git a/lib/authz.js b/lib/authz.js new file mode 100644 index 0000000..588a4dd --- /dev/null +++ b/lib/authz.js @@ -0,0 +1,175 @@ +import Mysql from './mysql.js' +import Permission from './permission.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', +] + +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) { + const inTree = await this.isInGroupTree( + credentials.group.id, targetGid, + ) + if (!inTree) { + 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 (resource === 'nameserver' && action === 'read') { + const usable = perm.nameserver?.usable ?? [] + if (usable.includes(String(objectId))) return allow() + } + + 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() + if (perm[resource]?.[action] === 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() + const permField = `perm_${action === 'delegate' ? 'delegate' : action}` + if (delegation[permField] === 1) return allow() + return deny(`You have no '${action}' 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 getDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + + const rows = await Mysql.execute( + `SELECT * FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [groupId, objectId, type], + ) + if (rows.length > 0) return rows[0] + + if (resource === 'zonerecord') { + return this.getZoneRecordPseudoDelegation(groupId, objectId) + } + return null + } + + 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 + WHERE d.nt_group_id = ? + AND r.nt_zone_record_id = ? + AND d.nt_object_type = 'ZONE' + AND d.deleted = 0`, + [groupId, zoneRecordId], + ) + return rows.length > 0 ? rows[0] : null + } + + capPermissions(userPerm, targetPerms) { + 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] + } + } + return capped + } +} + +function allow() { + return { allowed: true } +} + +function deny(msg) { + return { allowed: false, code: 404, msg } +} + +export default new Authz() diff --git a/lib/authz.test.js b/lib/authz.test.js new file mode 100644 index 0000000..e2a3ae3 --- /dev/null +++ b/lib/authz.test.js @@ -0,0 +1,543 @@ +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.js' +import ZoneRecord from './zone_record.js' +import Nameserver from './nameserver.js' +import Permission from './permission.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, + ) + 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 usable list', () => { + it('allows read of usable nameserver', async () => { + const r = await Authz.checkPermission( + credsFull, '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('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) + }) +}) diff --git a/lib/delegation.js b/lib/delegation.js new file mode 100644 index 0000000..cf02433 --- /dev/null +++ b/lib/delegation.js @@ -0,0 +1,203 @@ +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] === false ? 0 : 1 + } + + 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) + } + 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` + + return Mysql.execute(query, [objType, gid]) + } + + async getDelegates(oid, objType) { + 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` + + return Mysql.execute(query, [objType, oid]) + } + + async put(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 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, ...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], + }, + '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 3f1a214..1ad8828 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -55,7 +55,7 @@ class Group extends GroupBase { async get(args_orig) { const args = JSON.parse(JSON.stringify(args_orig)) - if (args.deleted === undefined) args.deleted = false + if (args.deleted === undefined && !args.id) args.deleted = false const include_subgroups = args.include_subgroups === true delete args.include_subgroups @@ -112,7 +112,7 @@ class Group extends GroupBase { for (const b of boolFields) { row[b] = row[b] === 1 } - if ([false, undefined].includes(args_orig.deleted)) delete row.deleted + if (args_orig.deleted === false) delete row.deleted const perm = await Permission.get({ gid: row.id }) if (perm) { diff --git a/lib/nameserver/store/mysql.js b/lib/nameserver/store/mysql.js index 2b04a20..8a1496f 100644 --- a/lib/nameserver/store/mysql.js +++ b/lib/nameserver/store/mysql.js @@ -33,7 +33,9 @@ class Nameserver extends NameserverBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + const origDeleted = args.deleted + if (args.deleted === undefined && !args.id) args.deleted = false + if (args.deleted === undefined) delete args.deleted if (args.name !== undefined) { args['ns.name'] = args.name @@ -65,7 +67,7 @@ class Nameserver extends NameserverBase { for (const b of boolFields) { row[b] = row[b] === 1 } - if (args.deleted === false) delete row.deleted + if (origDeleted === false) delete row.deleted } return dbToObject(rows) } @@ -74,9 +76,21 @@ class Nameserver extends NameserverBase { if (!args.id) return false const id = args.id delete args.id - // Mysql.debug(1) + + if (args.export?.type) { + const rows = await Mysql.execute( + ...Mysql.select('SELECT id FROM nt_nameserver_export_type', { + name: args.export.type, + }), + ) + if (rows.length > 0) args.export_type_id = rows[0].id + delete args.export.type + } + + const dbArgs = mapToDbColumn(objectToDb(args), nsDbMap) + if (Object.keys(dbArgs).length === 0) return true const r = await Mysql.execute( - ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, mapToDbColumn(args, nsDbMap)), + ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, dbArgs), ) return r.changedRows === 1 } diff --git a/lib/permission/index.js b/lib/permission/index.js index ef3883f..4460f8c 100644 --- a/lib/permission/index.js +++ b/lib/permission/index.js @@ -86,7 +86,8 @@ class Permission { AND p.deleted=${args.deleted === true ? 1 : 0} AND u.deleted=0 AND u.nt_user_id=?` - const rows = await Mysql.execute(...Mysql.select(query, [args.uid])) + const [q, p] = Mysql.select(query, [args.uid]) + const rows = await Mysql.execute(q, p) if (rows.length === 0) return const row = dbToObject(rows[0]) if ([false, undefined].includes(args.deleted)) delete row.deleted @@ -97,6 +98,9 @@ class Permission { if (!args.id) return false const id = args.id delete args.id + if (Array.isArray(args.usable_ns)) { + args.usable_ns = args.usable_ns.join(',') + } const r = await Mysql.execute( ...Mysql.update(`nt_perm`, `nt_perm_id=${id}`, mapToDbColumn(args, permDbMap)), ) @@ -126,7 +130,8 @@ class Permission { async getEffective(uid) { const userPerm = await this.get({ uid }) if (userPerm && userPerm.inherit === false) return userPerm - return this.getGroup({ uid }) + const groupPerm = await this.getGroup({ uid }) + return groupPerm } /** @@ -248,9 +253,11 @@ function dbToObject(row) { row.user.id = row.uid delete row.uid } - if (row.gid !== undefined) { + if (row.gid !== undefined && row.gid !== null) { row.group.id = row.gid delete row.gid + } else { + delete row.gid } row.nameserver.usable = [] if (![undefined, null, ''].includes(row.usable_ns)) { diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 88db192..8e3013e 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -33,7 +33,8 @@ class UserRepoMySQL extends UserBase { AND g.deleted=0 AND u.deleted=0 AND u.username = ? - AND g.name = ?` + AND g.name = ? + ORDER BY u.nt_user_id DESC` for (const u of await Mysql.execute(query, [username, groupName])) { if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) { @@ -55,8 +56,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)) @@ -74,6 +77,7 @@ class UserRepoMySQL extends UserBase { if (userId && inherit === false) { await Permission.create({ uid: userId, + gid: args.gid, inherit: false, name: `User ${args.username} perms`, }) @@ -85,7 +89,7 @@ class UserRepoMySQL extends UserBase { async get(args) { const origDeleted = args.deleted // capture before defaulting/removing args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + if (args.deleted === undefined && !args.id) args.deleted = false const include_subgroups = args.include_subgroups === true delete args.include_subgroups @@ -146,7 +150,7 @@ class UserRepoMySQL extends UserBase { for (const b of boolFields) { r[b] = r[b] === 1 } - if ([false, undefined].includes(origDeleted)) delete r.deleted + if (origDeleted === false) delete r.deleted const effectivePerm = await Permission.getEffective(r.id) if (effectivePerm) { @@ -176,6 +180,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/zone/store/mysql.js b/lib/zone/store/mysql.js index de30161..214f0b1 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -52,7 +52,9 @@ class ZoneRepoMySQL extends ZoneBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - args.deleted = args.deleted ?? false + const origDeleted = args.deleted + if (args.deleted === undefined && !args.id) args.deleted = false + if (args.deleted === undefined) delete args.deleted const filters = { search: args.search, @@ -128,7 +130,7 @@ class ZoneRepoMySQL extends ZoneBase { if (row['last_publish'] === undefined) delete row['last_publish'] if (/00:00:00/.test(row['last_publish'])) row['last_publish'] = null - if (args.deleted === false) delete row.deleted + if (origDeleted === false) delete row.deleted } return rows @@ -162,9 +164,16 @@ class ZoneRepoMySQL extends ZoneBase { if (!args.id) return false const id = args.id delete args.id + const dbArgs = mapToDbColumn(args, zoneDbMap) const r = await Mysql.execute( - ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), + ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, dbArgs), ) + if (r.changedRows > 0) { + await Mysql.execute( + 'UPDATE nt_zone SET serial = serial + 1 WHERE nt_zone_id = ?', + [id], + ) + } return r.changedRows === 1 } diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index c56f832..2d48958 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -21,7 +21,10 @@ class ZoneRecordMySQL extends ZoneRecordBase { if (g.length === 1) return g[0].id } - const rrArgs = args.ttl === undefined ? { ...args, default: { ttl: 0 } } : args + const qualified = await this.qualifyOwner({ ...args }) + const rrArgs = qualified.ttl === undefined + ? { ...qualified, default: { ttl: 0 } } + : qualified new RR[args.type](rrArgs) args = objectToDb(args) @@ -31,7 +34,9 @@ class ZoneRecordMySQL extends ZoneRecordBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + const origDeleted = args.deleted + if (args.deleted === undefined && !args.id) args.deleted = false + if (args.deleted === undefined) delete args.deleted if (args.type !== undefined) { args.type_id = RR.typeMap[args.type] delete args.type @@ -61,7 +66,7 @@ class ZoneRecordMySQL extends ZoneRecordBase { for (const b of boolFields) { row[b] = row[b] === 1 } - if (args.deleted === false) delete row.deleted + if (origDeleted === false) delete row.deleted } const zrObjects = dbToObject(rows) @@ -73,6 +78,10 @@ class ZoneRecordMySQL extends ZoneRecordBase { if (!args.id) return false const id = args.id delete args.id + if (args.type) { + args.type_id = RR.typeMap[args.type] + delete args.type + } const r = await Mysql.execute( ...Mysql.update(`nt_zone_record`, `nt_zone_record_id=${id}`, mapToDbColumn(args, zrDbMap)), ) @@ -92,6 +101,20 @@ class ZoneRecordMySQL extends ZoneRecordBase { const r = await Mysql.execute(...Mysql.delete(`nt_zone_record`, { nt_zone_record_id: args.id })) return r.affectedRows === 1 } + + async qualifyOwner(args) { + if (!args.owner || args.owner.endsWith('.') || !args.zid) return args + const rows = await Mysql.execute( + 'SELECT zone FROM nt_zone WHERE nt_zone_id = ?', + [args.zid], + ) + if (rows.length === 0) return args + const zone = rows[0].zone.endsWith('.') + ? rows[0].zone + : `${rows[0].zone}.` + args.owner = `${args.owner}.${zone}` + return args + } } export default ZoneRecordMySQL diff --git a/routes/authz.test.js b/routes/authz.test.js new file mode 100644 index 0000000..9122b6c --- /dev/null +++ b/routes/authz.test.js @@ -0,0 +1,375 @@ +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.js' +import ZoneRecord from '../lib/zone_record.js' +import Nameserver from '../lib/nameserver.js' +import Permission from '../lib/permission.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 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 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 */ } + for (const id of [4200, 4201]) { + 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_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 Nameserver.destroy({ id: NS.id }) + await ZoneRecord.destroy({ id: ZR_OUTSIDE.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 }) + } + 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('200 for GET /zone (list, no per-object check)', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone', + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + 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('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('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/) + }) +}) + +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/, + ) + }) +}) + +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) + }) +}) diff --git a/routes/delegation.js b/routes/delegation.js new file mode 100644 index 0000000..b24991e --- /dev/null +++ b/routes/delegation.js @@ -0,0 +1,216 @@ +import validate from '@nictool/validate' + +import Delegation from '../lib/delegation.js' +import Permission from '../lib/permission.js' +import { meta } from '../lib/util.js' + +const DELEG_PERM_CAP = { + ZONE: { + perm_write: ['zone', 'write'], + perm_delete: ['zone', 'delete'], + perm_delegate: ['zone', 'delegate'], + zone_perm_add_records: ['zonerecord', 'create'], + zone_perm_delete_records: ['zonerecord', 'delete'], + }, + ZONERECORD: { + perm_write: ['zonerecord', 'write'], + perm_delete: ['zonerecord', 'delete'], + perm_delegate: ['zonerecord', 'delegate'], + }, +} + +function capDelegationPerms(payload, perm, 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) { + if (mode === 'create') payload[field] = false + else delete payload[field] + } + } +} + +function DelegationRoutes(server) { + server.route([ + { + method: 'GET', + path: '/delegation', + options: { + 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, + }, + 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, 'create') + + 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: 'delegate', 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, 'edit') + + 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: 'delegate', idFrom: 'query.oid' } }, + validate: { + query: validate.delegation.DELETE, + failAction: 'log', + }, + 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, + } + + 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) + }, + }, + ]) +} + +export default DelegationRoutes + +export { Delegation, DelegationRoutes } diff --git a/routes/group.js b/routes/group.js index c9df9d1..a94ff5d 100644 --- a/routes/group.js +++ b/routes/group.js @@ -1,8 +1,30 @@ import validate from '@nictool/validate' import Group from '../lib/group/index.js' +import Authz from '../lib/authz.js' +import Permission from '../lib/permission.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', +]) + +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 GroupRoutes(server) { server.route([ { @@ -36,20 +58,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) if (groups.length !== 1 && !request.query.include_subgroups) { return h @@ -77,17 +104,30 @@ 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 { 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(request.payload) + 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 }) return h @@ -105,16 +145,29 @@ function GroupRoutes(server) { method: 'PUT', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'write', idFrom: 'params.id' } }, 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) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ gid: id }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } + await Group.put({ ...request.payload, id }) const groups = await Group.get({ id }) @@ -134,6 +187,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/index.js b/routes/index.js index 16f4907..c873a87 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 ec2d13a..3e352fe 100644 --- a/routes/nameserver.js +++ b/routes/nameserver.js @@ -18,8 +18,9 @@ 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) @@ -41,6 +42,7 @@ function NameserverRoutes(server) { method: 'POST', path: '/nameserver', options: { + app: { permission: { resource: 'nameserver', action: 'create' } }, validate: { payload: validate.nameserver.POST, }, @@ -69,6 +71,7 @@ function NameserverRoutes(server) { method: 'PUT', path: '/nameserver/{id}', options: { + app: { permission: { resource: 'nameserver', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.nameserver.PUT, }, @@ -100,6 +103,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/permission.js b/routes/permission.js index b06f537..21e3753 100644 --- a/routes/permission.js +++ b/routes/permission.js @@ -64,6 +64,38 @@ function PermissionRoutes(server) { .code(201) }, }, + { + method: 'PUT', + path: '/permission/{id}', + options: { + 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) + } + + await Permission.put({ ...request.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}', diff --git a/routes/session.js b/routes/session.js index 610bbc3..cbc0cfe 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.js' +import Permission from '../lib/permission.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/user.js b/routes/user.js index 04311ea..989582d 100644 --- a/routes/user.js +++ b/routes/user.js @@ -1,8 +1,30 @@ import validate from '@nictool/validate' import User from '../lib/user/index.js' +import Authz from '../lib/authz.js' +import Permission from '../lib/permission.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', +]) + +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 UserRoutes(server) { server.route([ { @@ -44,19 +66,22 @@ function UserRoutes(server) { method: 'GET', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.user.GET_req, }, response: { schema: validate.user.GET_res, + failAction: 'log', }, 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 @@ -69,13 +94,23 @@ function UserRoutes(server) { .code(204) } + const uid = getArgs.id const gid = parseInt(users[0].gid, 10) delete users[0].gid + const perm = await Permission.getEffective(uid) + const groupPerm = await Permission.getGroup({ + uid, deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: users, group: { id: gid }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `here's your user`, @@ -88,18 +123,28 @@ 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, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { + 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(request.payload) - if (!uid) { - console.log(`POST /user oops`) // TODO + + 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 }) @@ -122,16 +167,25 @@ 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, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) + 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 args = { ...request.payload, id } if (args.password) { @@ -141,6 +195,21 @@ 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 @@ -161,11 +230,13 @@ function UserRoutes(server) { method: 'DELETE', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.user.DELETE, }, response: { schema: validate.user.GET_res, + failAction: 'log', }, tags: ['api'], }, diff --git a/routes/zone.js b/routes/zone.js index 6d695d9..f4bbbb4 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -10,6 +10,7 @@ function ZoneRoutes(server) { method: 'GET', path: '/zone/{id?}', options: { + app: { permission: { resource: 'zone', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.zone.GET_req, }, @@ -19,11 +20,12 @@ 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.query.gid != null) { const gid = Number.isInteger(request.query.gid) @@ -38,6 +40,7 @@ function ZoneRoutes(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 deleted = getArgs.deleted ?? false const countArgs = { deleted, ...(getArgs.id ? { id: getArgs.id } : {}), @@ -74,6 +77,7 @@ function ZoneRoutes(server) { method: 'POST', path: '/zone', options: { + app: { permission: { resource: 'zone', action: 'create' } }, validate: { payload: validate.zone.POST, }, @@ -102,8 +106,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, @@ -164,6 +170,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_record.js b/routes/zone_record.js index 0ebf823..b05b67a 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -33,6 +33,7 @@ function ZoneRecordRoutes(server) { method: 'GET', path: '/zone_record/{id?}', options: { + app: { permission: { resource: 'zonerecord', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.zone_record.GET_req, }, @@ -43,8 +44,9 @@ function ZoneRecordRoutes(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.zid) getArgs.zid = parseInt(request.query.zid, 10) @@ -66,6 +68,7 @@ function ZoneRecordRoutes(server) { method: 'POST', path: '/zone_record', options: { + app: { permission: { resource: 'zonerecord', action: 'create' } }, validate: { payload: validate.zone_record.POST, }, @@ -94,6 +97,7 @@ function ZoneRecordRoutes(server) { method: 'PUT', path: '/zone_record/{id}', options: { + app: { permission: { resource: 'zonerecord', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.zone_record.PUT, }, @@ -127,6 +131,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, },