From fdaf65e27f4183939ab62a5fa2cb10706d2bc253 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 1/2] 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 | 177 ++++++++++++ lib/authz.test.js | 543 +++++++++++++++++++++++++++++++++++++ lib/delegation.js | 203 ++++++++++++++ routes/authz.test.js | 375 +++++++++++++++++++++++++ routes/delegation.js | 216 +++++++++++++++ routes/group.js | 60 +++- routes/group.test.js | 5 + routes/index.js | 5 + routes/nameserver.js | 8 +- routes/nameserver.test.js | 2 + routes/permission.js | 32 +++ routes/session.js | 21 ++ routes/test/permissions.js | 38 +++ routes/user.js | 83 +++++- routes/user.test.js | 2 + routes/zone.js | 11 +- routes/zone.test.js | 6 + routes/zone_record.js | 4 + routes/zone_record.test.js | 2 + 20 files changed, 1872 insertions(+), 13 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..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..1315729 --- /dev/null +++ b/lib/authz.js @@ -0,0 +1,177 @@ +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', +] + +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`) + // top-level groups have no parent, so the tree walk below can't see them + return allow() + } + + 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..2593ed2 --- /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/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, + ) + 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/routes/authz.test.js b/routes/authz.test.js new file mode 100644 index 0000000..f90b0c5 --- /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/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 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..dd50b95 --- /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/index.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 d835f40..d70efa5 100644 --- a/routes/group.js +++ b/routes/group.js @@ -3,8 +3,30 @@ 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', +]) + +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) // Return an array like the other object types (zone/nameserver/user/ // zone_record) rather than a bare object, for a consistent API contract. @@ -68,17 +95,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 @@ -96,16 +136,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 }) @@ -125,6 +178,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..1de96c2 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, }, @@ -98,6 +101,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..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 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/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..ddeea3f 100644 --- a/routes/user.js +++ b/routes/user.js @@ -2,8 +2,30 @@ 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', +]) + +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([ { @@ -45,19 +67,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 @@ -70,13 +95,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`, @@ -89,18 +124,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 }) @@ -123,16 +168,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 } // no salt passed: a password change always gets a fresh one @@ -142,6 +196,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.response({ meta: { api: meta.api, msg: `user not found` } }).code(404) @@ -160,11 +229,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/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..3062972 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -11,6 +11,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, }, @@ -20,11 +21,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) @@ -43,6 +45,7 @@ function ZoneRoutes(server) { getArgs.gid = await Group.subgroupGids(getArgs.gid) } + const deleted = getArgs.deleted ?? false const countArgs = { deleted, ...(getArgs.id ? { id: getArgs.id } : {}), @@ -79,6 +82,7 @@ function ZoneRoutes(server) { method: 'POST', path: '/zone', options: { + app: { permission: { resource: 'zone', action: 'create' } }, validate: { payload: validate.zone.POST, }, @@ -107,8 +111,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, @@ -165,6 +171,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..0dfa60e 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, }, @@ -90,6 +91,7 @@ function ZoneRecordRoutes(server) { method: 'POST', path: '/zone_record', options: { + app: { permission: { resource: 'zonerecord', action: 'create' } }, validate: { payload: validate.zone_record.POST, }, @@ -118,6 +120,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, }, @@ -149,6 +152,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 e357974b7da5c34532c7a46592ec7485090be083 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:16:23 +0100 Subject: [PATCH 2/2] authz: close review gaps, match v2 edge semantics - scope directly delegated record collections to the delegated rows instead of exposing every record in the zone - v2 writes root perm rows with uid=0; treat those like NULL - rebuild authorization ancestry when a group subtree moves - cap what a caller may grant or revoke, including explicit-row transitions and usable-nameserver edits; deny self-permission writes - revalidate sessions against live user/group/session rows and idle expiry before trusting credentials - rebuild record rows on type change so stale rdata columns clear; reuse deleted permission rows rather than accumulating duplicates --- lib/authz-plugin.js | 217 +++++++++-- lib/authz.js | 372 +++++++++++++++++- lib/authz.test.js | 46 ++- lib/delegation.js | 31 +- 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 | 671 ++++++++++++++++++++++++++++++++- routes/delegation.js | 50 ++- routes/group.js | 40 +- routes/nameserver.js | 16 +- routes/permission.js | 40 +- routes/permission.test.js | 67 +--- routes/session.test.js | 9 +- routes/user.js | 91 ++++- routes/zone.js | 37 +- routes/zone_record.js | 32 +- 25 files changed, 1866 insertions(+), 218 deletions(-) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js index 3b29e4d..872888e 100644 --- a/lib/authz-plugin.js +++ b/lib/authz-plugin.js @@ -8,60 +8,210 @@ const TYPE_TO_RESOURCE = { 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 { idFrom } = 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 (idFrom) { - objectId = resolveId(request, idFrom) + if (permCfg.idFrom) { + objectId = resolveId(request, permCfg.idFrom) if (objectId !== undefined) objectId = Number(objectId) } - // List requests (no objectId) don't need per-object authz + 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) { - return h.continue + 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) } - // Delegation: resolve resource from the type field - if (action === 'delegate') { + if (action.endsWith('Delegation') || action === 'delegate') { const type = request.payload?.type ?? request.query?.type - if (type && TYPE_TO_RESOURCE[type]) { - resource = TYPE_TO_RESOURCE[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') { - const targetGid = await resolveTargetGroup( + 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, ) - if (targetGid) opts = { targetGroupId: targetGid } + opts = { + targetGroupId: target?.gid, + targetZoneId: target?.zid, + } } const result = await Authz.checkPermission( credentials, resource, action, objectId, opts, ) + if (!result.allowed) return respond(result, h) - if (result.allowed) return h.continue + // 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 h.response({ - error_code: result.code, - error_msg: result.msg, - }).code(403).takeover() + 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] @@ -69,24 +219,45 @@ function resolveId(request, idFrom) { 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) +// 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 = ?', + 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ? AND deleted = 0', [zid], ) - if (rows.length > 0) return rows[0].nt_group_id + 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 index 1315729..98aad4f 100644 --- a/lib/authz.js +++ b/lib/authz.js @@ -26,6 +26,17 @@ const PERM_FIELDS = [ '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) @@ -36,11 +47,27 @@ class Authz { return deny(`Not allowed to create new ${resource}`) } const targetGid = opts?.targetGroupId - if (targetGid) { + 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})`, @@ -62,13 +89,22 @@ class Authz { 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`) - // top-level groups have no parent, so the tree walk below can't see them + if (action === 'read') return allow() + } + + if ( + resource === 'nameserver' + && action === 'read' + && await this.isActiveObject(resource, objectId) + ) { return allow() } - if (resource === 'nameserver' && action === 'read') { - const usable = perm.nameserver?.usable ?? [] - if (usable.includes(String(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) @@ -78,7 +114,8 @@ class Authz { if (await this.isInGroupTree(credentials.group.id, objGroupId)) { if (action === 'read') return allow() - if (perm[resource]?.[action] === true) 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`) } @@ -86,10 +123,28 @@ class Authz { credentials.group.id, objectId, resource, ) if (delegation) { - if (action === 'read') return allow() - const permField = `perm_${action === 'delegate' ? 'delegate' : action}` + 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 '${action}' permission for the delegated object`) + return deny(`You have no '${displayAction}' permission for the delegated object`) } return deny( @@ -120,37 +175,138 @@ class Authz { 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 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] + 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 d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, [groupId, zoneRecordId], ) return rows.length > 0 ? rows[0] : null } - capPermissions(userPerm, targetPerms) { + capPermissions(userPerm, targetPerms, existingPerm) { if (!targetPerms || !userPerm) return targetPerms const capped = { ...targetPerms } @@ -162,16 +318,194 @@ class Authz { 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 allow() { - return { allowed: true } +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 index 2593ed2..a1fc345 100644 --- a/lib/authz.test.js +++ b/lib/authz.test.js @@ -245,6 +245,7 @@ describe('checkPermission', () => { it('allows create when user has permission', async () => { const r = await Authz.checkPermission( credsFull, 'zone', 'create', undefined, + { targetGroupId: 4200 }, ) assert.equal(r.allowed, true) }) @@ -325,10 +326,10 @@ describe('checkPermission', () => { }) }) - describe('nameserver usable list', () => { - it('allows read of usable nameserver', async () => { + describe('nameserver reads', () => { + it('allows authenticated read of an active nameserver', async () => { const r = await Authz.checkPermission( - credsFull, 'nameserver', 'read', 4200, + credsLimited, 'nameserver', 'read', 4200, ) assert.equal(r.allowed, true) }) @@ -380,6 +381,19 @@ describe('checkPermission', () => { 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, @@ -540,4 +554,30 @@ describe('capPermissions', () => { 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 index cf02433..abe4023 100644 --- a/lib/delegation.js +++ b/lib/delegation.js @@ -39,7 +39,7 @@ class Delegation { } for (const f of PERM_FIELDS) { - row[f] = args[f] === false ? 0 : 1 + row[f] = args[f] === true ? 1 : 0 } await Mysql.execute(...Mysql.insert('nt_delegate', row)) @@ -56,7 +56,7 @@ class Delegation { if (!meta) return [] if (oid !== undefined) { - return this.getDelegates(oid, objType) + return this.getDelegates(oid, objType, gid) } if (gid !== undefined) { return this.getDelegated(gid, objType, meta) @@ -84,12 +84,13 @@ class Delegation { WHERE d.nt_object_type = ? AND d.nt_group_id = ? AND d.deleted = 0 - AND o.deleted = 0` + AND o.deleted = 0 + AND g.deleted = 0` return Mysql.execute(query, [objType, gid]) } - async getDelegates(oid, objType) { + async getDelegates(oid, objType, gid) { const query = `SELECT d.nt_group_id, d.nt_object_id, @@ -106,16 +107,20 @@ class Delegation { 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 d.deleted = 0 + AND g.deleted = 0` - return Mysql.execute(query, [objType, oid]) + 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 FROM nt_delegate + `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], ) @@ -142,7 +147,15 @@ class Delegation { ) await this.log( - { nt_group_id: gid, nt_object_id: oid, nt_object_type: type, ...updates }, + { + 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', ) @@ -167,6 +180,8 @@ class Delegation { 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', ) 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 index f90b0c5..541f951 100644 --- a/routes/authz.test.js +++ b/routes/authz.test.js @@ -49,6 +49,16 @@ const U_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, @@ -92,6 +102,24 @@ const ZR_OUTSIDE = { 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, @@ -110,7 +138,12 @@ 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]) { + 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 }) } @@ -164,6 +197,7 @@ before(async () => { 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) @@ -207,8 +241,12 @@ before(async () => { 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 }) @@ -217,6 +255,9 @@ after(async () => { 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 }) } @@ -256,13 +297,24 @@ describe('authz plugin - zone routes', () => { assert.ok(res.result.error_code) }) - it('200 for GET /zone (list, no per-object check)', async () => { + it('GET /zone defaults to the caller group', async () => { const res = await server.inject({ method: 'GET', url: '/zone', - headers: authFull.headers, + 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 () => { @@ -295,6 +347,49 @@ describe('authz plugin - zone routes', () => { 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', @@ -306,6 +401,34 @@ 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 () => { + 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', @@ -326,6 +449,22 @@ describe('authz plugin - user self-ops', () => { 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', () => { @@ -352,6 +491,17 @@ describe('authz plugin - group self-ops', () => { /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', () => { @@ -372,4 +522,519 @@ describe('authz plugin - zone record delegation', () => { }) 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 index dd50b95..2a6c181 100644 --- a/routes/delegation.js +++ b/routes/delegation.js @@ -1,30 +1,37 @@ 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_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) { +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) { + if ( + perm[resource]?.[action] !== true + || (sourceDelegation && sourceDelegation[field] !== 1) + ) { if (mode === 'create') payload[field] = false else delete payload[field] } @@ -37,6 +44,7 @@ function DelegationRoutes(server) { method: 'GET', path: '/delegation', options: { + app: { permission: { resource: 'zone', action: 'readDelegation' } }, validate: { query: validate.delegation.GET_req, }, @@ -71,6 +79,7 @@ function DelegationRoutes(server) { app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'payload.oid' } }, validate: { payload: validate.delegation.POST, + options: { noDefaults: true }, }, response: { schema: validate.delegation.GET_res, @@ -80,7 +89,9 @@ function DelegationRoutes(server) { handler: async (request, h) => { const { user } = request.auth.credentials const perm = await Permission.getEffective(user.id) - capDelegationPerms(request.payload, perm, 'create') + const sourceDelegation = await sourceDelegationFor(request) + capDelegationPerms(request.payload, perm, sourceDelegation, 'create') + setActor(request) const result = await Delegation.create(request.payload) @@ -117,7 +128,7 @@ function DelegationRoutes(server) { method: 'PUT', path: '/delegation', options: { - app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'payload.oid' } }, + app: { permission: { resource: 'zone', action: 'editDelegation', idFrom: 'payload.oid' } }, validate: { payload: validate.delegation.PUT, }, @@ -129,7 +140,8 @@ function DelegationRoutes(server) { handler: async (request, h) => { const { user } = request.auth.credentials const perm = await Permission.getEffective(user.id) - capDelegationPerms(request.payload, perm, 'edit') + capDelegationPerms(request.payload, perm, null, 'edit') + setActor(request) const result = await Delegation.put(request.payload) @@ -166,10 +178,9 @@ function DelegationRoutes(server) { method: 'DELETE', path: '/delegation', options: { - app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'query.oid' } }, + app: { permission: { resource: 'zone', action: 'deleteDelegation', idFrom: 'query.oid' } }, validate: { query: validate.delegation.DELETE, - failAction: 'log', }, response: { schema: validate.delegation.GET_res, @@ -181,6 +192,8 @@ function DelegationRoutes(server) { 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) @@ -211,6 +224,25 @@ function DelegationRoutes(server) { ]) } +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 d70efa5..4450d18 100644 --- a/routes/group.js +++ b/routes/group.js @@ -16,6 +16,14 @@ const PERM_FIELDS = new Set([ '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)) { @@ -27,12 +35,23 @@ function extractPermFields(payload) { 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, }, @@ -46,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) @@ -112,7 +131,7 @@ function GroupRoutes(server) { request.payload = Authz.capPermissions(userPerm, request.payload) const permFields = extractPermFields(request.payload) - const gid = await Group.create(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 }) @@ -136,7 +155,14 @@ function GroupRoutes(server) { method: 'PUT', path: '/group/{id}', options: { - app: { permission: { resource: 'group', action: 'write', idFrom: 'params.id' } }, + app: { + permission: { + resource: 'group', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.parent_gid', + }, + }, validate: { payload: validate.group.PUT, options: { allowUnknown: true }, @@ -151,15 +177,15 @@ function GroupRoutes(server) { 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 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) { - const perm = await Permission.get({ gid: id }) - if (perm) await Permission.put({ id: perm.id, ...permFields }) + if (existingPerm) await Permission.put({ id: existingPerm.id, ...permFields }) } - await Group.put({ ...request.payload, id }) + await Group.put({ ...pickFields(request.payload, GROUP_PUT_FIELDS), id }) const groups = await Group.get({ id }) diff --git a/routes/nameserver.js b/routes/nameserver.js index 1de96c2..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, }, @@ -23,7 +31,13 @@ function NameserverRoutes(server) { 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) diff --git a/routes/permission.js b/routes/permission.js index 21e3753..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 }) @@ -68,6 +85,7 @@ function PermissionRoutes(server) { method: 'PUT', path: '/permission/{id}', options: { + app: { permission: { resource: 'permission', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.permission.POST, }, @@ -85,7 +103,26 @@ function PermissionRoutes(server) { .code(404) } - await Permission.put({ ...request.payload, id }) + 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 @@ -100,6 +137,7 @@ function PermissionRoutes(server) { 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.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/user.js b/routes/user.js index ddeea3f..1bfc531 100644 --- a/routes/user.js +++ b/routes/user.js @@ -15,6 +15,16 @@ const PERM_FIELDS = new Set([ '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)) { @@ -26,12 +36,32 @@ function extractPermFields(payload) { 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, }, @@ -50,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({ @@ -73,7 +103,6 @@ function UserRoutes(server) { }, response: { schema: validate.user.GET_res, - failAction: 'log', }, tags: ['api'], }, @@ -95,23 +124,18 @@ 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 gid = prepareUserResponse(users[0]) const groupPerm = await Permission.getGroup({ - uid, deleted: false, + uid: getArgs.id, deleted: false, }) - if (perm && groupPerm) { - perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + if (users[0].permissions && groupPerm) { + users[0].permissions.nameserver.usable = groupPerm.nameserver?.usable ?? [] } return h .response({ user: users, group: { id: gid }, - permissions: perm ?? {}, meta: { api: meta.api, msg: `here's your user`, @@ -131,7 +155,6 @@ function UserRoutes(server) { }, response: { schema: validate.user.GET_res, - failAction: 'log', }, tags: ['api'], }, @@ -141,7 +164,7 @@ function UserRoutes(server) { request.payload = Authz.capPermissions(userPerm, request.payload) const permFields = extractPermFields(request.payload) - const uid = await User.create(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 }) @@ -149,8 +172,7 @@ function UserRoutes(server) { } 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({ @@ -175,7 +197,6 @@ function UserRoutes(server) { }, response: { schema: validate.user.GET_res, - failAction: 'log', }, tags: ['api'], }, @@ -183,10 +204,41 @@ function UserRoutes(server) { 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 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 @@ -215,7 +267,7 @@ function UserRoutes(server) { 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({ @@ -235,7 +287,6 @@ function UserRoutes(server) { }, response: { schema: validate.user.GET_res, - failAction: 'log', }, tags: ['api'], }, @@ -255,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/zone.js b/routes/zone.js index 3062972..6b25dbb 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -2,16 +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' } }, + app: { + permission: { + resource: 'zone', + action: 'read', + idFrom: 'params.id', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.zone.GET_req, }, @@ -28,6 +41,9 @@ function ZoneRoutes(server) { 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 @@ -45,20 +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 @@ -130,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) @@ -140,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, }, diff --git a/routes/zone_record.js b/routes/zone_record.js index 0dfa60e..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,7 +34,14 @@ function ZoneRecordRoutes(server) { method: 'GET', path: '/zone_record/{id?}', options: { - app: { permission: { resource: 'zonerecord', action: 'read', idFrom: 'params.id' } }, + app: { + permission: { + resource: 'zonerecord', + action: 'read', + idFrom: 'params.id', + list: { resource: 'zone', idFrom: 'query.zid' }, + }, + }, validate: { query: validate.zone_record.GET_req, }, @@ -56,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, @@ -120,7 +139,14 @@ function ZoneRecordRoutes(server) { method: 'PUT', path: '/zone_record/{id}', options: { - app: { permission: { resource: 'zonerecord', action: 'write', idFrom: 'params.id' } }, + app: { + permission: { + resource: 'zonerecord', + action: 'write', + idFrom: 'params.id', + targetCreateResource: 'zonerecord', + }, + }, validate: { payload: validate.zone_record.PUT, },