diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js new file mode 100644 index 0000000..3b29e4d --- /dev/null +++ b/lib/authz-plugin.js @@ -0,0 +1,92 @@ +import Authz from './authz.js' +import Mysql from './mysql.js' + +const TYPE_TO_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', + NAMESERVER: 'nameserver', + GROUP: 'group', +} + +const authzPlugin = { + name: 'nt-authz', + register(server) { + server.ext('onPreHandler', async (request, h) => { + const permCfg = request.route.settings.app?.permission + if (!permCfg) return h.continue + + if (!request.auth.isAuthenticated) return h.continue + + let { resource, action } = permCfg + const { idFrom } = permCfg + const credentials = request.auth.credentials + + let objectId + if (idFrom) { + objectId = resolveId(request, idFrom) + if (objectId !== undefined) objectId = Number(objectId) + } + + // List requests (no objectId) don't need per-object authz + if (action === 'read' && objectId === undefined) { + return h.continue + } + + // Delegation: resolve resource from the type field + if (action === 'delegate') { + const type = request.payload?.type ?? request.query?.type + if (type && TYPE_TO_RESOURCE[type]) { + resource = TYPE_TO_RESOURCE[type] + } + } + + let opts + if (action === 'create') { + const targetGid = await resolveTargetGroup( + request, resource, + ) + if (targetGid) opts = { targetGroupId: targetGid } + } + + const result = await Authz.checkPermission( + credentials, resource, action, objectId, opts, + ) + + if (result.allowed) return h.continue + + return h.response({ + error_code: result.code, + error_msg: result.msg, + }).code(403).takeover() + }) + }, +} + +function resolveId(request, idFrom) { + const [source, key] = idFrom.split('.') + if (source === 'params') return request.params[key] + if (source === 'payload') return request.payload?.[key] + if (source === 'query') return request.query?.[key] +} + +async function resolveTargetGroup(request, resource) { + const gid = request.payload?.gid + ?? request.payload?.nt_group_id + ?? request.payload?.parent_gid + if (gid) return Number(gid) + + if (resource === 'zonerecord') { + const zid = request.payload?.zid ?? request.payload?.nt_zone_id + if (zid) { + const rows = await Mysql.execute( + 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?', + [zid], + ) + if (rows.length > 0) return rows[0].nt_group_id + } + } + + return null +} + +export default authzPlugin diff --git a/lib/authz.js b/lib/authz.js new file mode 100644 index 0000000..588a4dd --- /dev/null +++ b/lib/authz.js @@ -0,0 +1,175 @@ +import Mysql from './mysql.js' +import Permission from './permission.js' + +const RESOURCE_QUERIES = { + zone: 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?', + zonerecord: `SELECT z.nt_group_id FROM nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE r.nt_zone_record_id = ?`, + user: 'SELECT nt_group_id FROM nt_user WHERE nt_user_id = ?', + group: 'SELECT parent_group_id AS nt_group_id FROM nt_group WHERE nt_group_id = ?', + nameserver: 'SELECT nt_group_id FROM nt_nameserver WHERE nt_nameserver_id = ?', +} + +const DELEGATE_TYPE = { + zone: 'ZONE', + zonerecord: 'ZONERECORD', + nameserver: 'NAMESERVER', + group: 'GROUP', +} + +const PERM_FIELDS = [ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', +] + +class Authz { + async checkPermission(credentials, resource, action, objectId, opts) { + const perm = await Permission.getEffective(credentials.user.id) + if (!perm) return deny(`No permissions found`) + + if (action === 'create') { + if (perm[resource]?.create !== true) { + return deny(`Not allowed to create new ${resource}`) + } + const targetGid = opts?.targetGroupId + if (targetGid) { + const inTree = await this.isInGroupTree( + credentials.group.id, targetGid, + ) + if (!inTree) { + return deny( + `No Access Allowed to that object` + + ` (${DELEGATE_TYPE[resource] ?? 'GROUP'} : ${targetGid})`, + ) + } + } + return allow() + } + + if (resource === 'user' && objectId === credentials.user.id) { + if (action === 'delete') return deny(`Not allowed to delete self`) + if (action === 'write') { + if (perm.self_write !== true) return deny(`Not allowed to modify self`) + return allow() + } + return allow() + } + + if (resource === 'group' && objectId === credentials.group.id) { + if (action === 'write') return deny(`Not allowed to edit your own group`) + if (action === 'delete') return deny(`Not allowed to delete your own group`) + } + + if (resource === 'nameserver' && action === 'read') { + const usable = perm.nameserver?.usable ?? [] + if (usable.includes(String(objectId))) return allow() + } + + const objGroupId = await this.getObjectGroupId(resource, objectId) + if (objGroupId === null) { + return deny(`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`) + } + + if (await this.isInGroupTree(credentials.group.id, objGroupId)) { + if (action === 'read') return allow() + if (perm[resource]?.[action] === true) return allow() + return deny(`You have no '${action}' permission for ${resource} objects`) + } + + const delegation = await this.getDelegateAccess( + credentials.group.id, objectId, resource, + ) + if (delegation) { + if (action === 'read') return allow() + const permField = `perm_${action === 'delegate' ? 'delegate' : action}` + if (delegation[permField] === 1) return allow() + return deny(`You have no '${action}' permission for the delegated object`) + } + + return deny( + `No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`, + ) + } + + async getObjectGroupId(resource, objectId) { + const query = RESOURCE_QUERIES[resource] + if (!query) return null + + const rows = await Mysql.execute(query, [objectId]) + if (rows.length === 0) return null + + let gid = rows[0].nt_group_id + if (resource === 'group' && (gid === 0 || gid === null)) gid = 1 + return gid + } + + async isInGroupTree(userGroupId, targetGroupId) { + if (userGroupId === targetGroupId) return true + + const rows = await Mysql.execute( + `SELECT COUNT(*) AS count FROM nt_group_subgroups + WHERE nt_group_id = ? AND nt_subgroup_id = ?`, + [userGroupId, targetGroupId], + ) + return rows[0].count > 0 + } + + async getDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + + const rows = await Mysql.execute( + `SELECT * FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [groupId, objectId, type], + ) + if (rows.length > 0) return rows[0] + + if (resource === 'zonerecord') { + return this.getZoneRecordPseudoDelegation(groupId, objectId) + } + return null + } + + async getZoneRecordPseudoDelegation(groupId, zoneRecordId) { + const rows = await Mysql.execute( + `SELECT d.*, 1 AS pseudo FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_id = d.nt_object_id + WHERE d.nt_group_id = ? + AND r.nt_zone_record_id = ? + AND d.nt_object_type = 'ZONE' + AND d.deleted = 0`, + [groupId, zoneRecordId], + ) + return rows.length > 0 ? rows[0] : null + } + + capPermissions(userPerm, targetPerms) { + if (!targetPerms || !userPerm) return targetPerms + + const capped = { ...targetPerms } + for (const field of PERM_FIELDS) { + if (capped[field] === undefined) continue + const [resource] = field.split('_', 2) + const remaining = field.slice(resource.length + 1) + if (userPerm[resource]?.[remaining] !== true) { + delete capped[field] + } + } + return capped + } +} + +function allow() { + return { allowed: true } +} + +function deny(msg) { + return { allowed: false, code: 404, msg } +} + +export default new Authz() diff --git a/lib/authz.test.js b/lib/authz.test.js new file mode 100644 index 0000000..e2a3ae3 --- /dev/null +++ b/lib/authz.test.js @@ -0,0 +1,543 @@ +import assert from 'node:assert/strict' +import { describe, it, after, before } from 'node:test' + +import Group from './group/index.js' +import User from './user/index.js' +import Zone from './zone.js' +import ZoneRecord from './zone_record.js' +import Nameserver from './nameserver.js' +import Permission from './permission.js' +import Delegation from './delegation.js' +import Authz from './authz.js' +import Mysql from './mysql.js' + +const G_ROOT = { + id: 4200, + parent_gid: 0, + name: 'authz-root', +} +const G_CHILD = { + id: 4201, + parent_gid: 4200, + name: 'authz-child', +} +const G_OUTSIDE = { + id: 4202, + parent_gid: 0, + name: 'authz-outside', +} + +const U_FULL = { + id: 4200, + gid: 4200, + username: 'authz-full', + email: 'authz-full@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'Full', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_LIMITED = { + id: 4201, + gid: 4202, + username: 'authz-limited', + email: 'authz-limited@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'Limited', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_NOSELF = { + id: 4202, + gid: 4200, + username: 'authz-noself', + email: 'authz-noself@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'No', + last_name: 'Self', + inherit_group_permissions: false, +} + +const Z_INTREE = { + id: 4200, + gid: 4200, + zone: 'authz.example.com.', + mailaddr: 'hostmaster.authz.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const Z_OUTSIDE = { + id: 4201, + gid: 4202, + zone: 'authz-out.example.com.', + mailaddr: 'hostmaster.authz-out.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} + +const ZR_INTREE = { + id: 4200, + zid: 4200, + owner: 'test.authz.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const ZR_PSEUDO = { + id: 4201, + zid: 4201, + owner: 'test.authz-out.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} +const ZR_DIRECT = { + id: 4202, + zid: 4201, + owner: 'direct.authz-out.example.com.', + type: 'A', + address: '192.0.2.3', + ttl: 3600, +} + +const NS = { + id: 4200, + gid: 4200, + name: 'ns1.authz.example.com.', + ttl: 3600, + description: 'authz test ns', + address: '192.0.2.10', + export: { type: 'bind', interval: 0, serials: 0 }, +} + +// Credentials objects matching JWT shape +const credsFull = { user: { id: 4200 }, group: { id: 4200 } } +const credsLimited = { user: { id: 4201 }, group: { id: 4202 } } +const credsNoself = { user: { id: 4202 }, group: { id: 4200 } } + +before(async () => { + // Clean up stale data from prior crashed runs + for (const d of [ + { gid: 4200, oid: 4202, type: 'ZONERECORD' }, + { gid: 4200, oid: 4201, type: 'ZONE' }, + ]) { + try { await Delegation.delete(d) } catch { /* ignore */ } + } + for (const id of [4200, 4201, 4202]) { + await ZoneRecord.destroy({ id }) + } + for (const id of [4200, 4201]) await Zone.destroy({ id }) + await Nameserver.destroy({ id: 4200 }) + for (const id of [4200, 4201, 4202]) { + const p = await Permission.get({ uid: id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id }) + } + for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + + for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) + for (const u of [U_FULL, U_LIMITED, U_NOSELF]) await User.create(u) + + // Set permissions for full-perm user + const fullPerm = await Permission.get({ uid: U_FULL.id }) + if (fullPerm) { + await Permission.put({ + id: fullPerm.id, + self_write: 1, + group_write: 1, group_create: 1, group_delete: 1, + zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, + zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, user_create: 1, user_delete: 1, + nameserver_write: 1, nameserver_create: 1, nameserver_delete: 1, + usable_ns: '4200', + }) + } + + // Set permissions for limited user — all false (defaults) + const limPerm = await Permission.get({ uid: U_LIMITED.id }) + if (limPerm) { + await Permission.put({ + id: limPerm.id, + self_write: 0, + group_write: 0, group_create: 0, group_delete: 0, + zone_write: 0, zone_create: 0, zone_delete: 0, zone_delegate: 0, + zonerecord_write: 0, zonerecord_create: 0, zonerecord_delete: 0, + zonerecord_delegate: 0, + user_write: 0, user_create: 0, user_delete: 0, + nameserver_write: 0, nameserver_create: 0, nameserver_delete: 0, + usable_ns: '', + }) + } + + // Set permissions for noself user — has resource perms but no self_write + const noselfPerm = await Permission.get({ uid: U_NOSELF.id }) + if (noselfPerm) { + await Permission.put({ + id: noselfPerm.id, + self_write: 0, + zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, + zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, user_create: 1, user_delete: 1, + }) + } + + // Create zones, zone records, nameserver + await Zone.create(Z_INTREE) + await Zone.create(Z_OUTSIDE) + await ZoneRecord.create(ZR_INTREE) + await ZoneRecord.create(ZR_PSEUDO) + await ZoneRecord.create(ZR_DIRECT) + await Nameserver.create(NS) + + // Create delegations + await Delegation.create({ + gid: 4200, oid: 4201, type: 'ZONE', + perm_write: true, perm_delete: false, perm_delegate: true, + }) + await Delegation.create({ + gid: 4200, oid: 4202, type: 'ZONERECORD', + perm_write: true, perm_delete: false, perm_delegate: false, + }) +}) + +after(async () => { + // Teardown in reverse dependency order + await Delegation.delete({ gid: 4200, oid: 4202, type: 'ZONERECORD' }) + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + await Nameserver.destroy({ id: NS.id }) + await ZoneRecord.destroy({ id: ZR_DIRECT.id }) + await ZoneRecord.destroy({ id: ZR_PSEUDO.id }) + await ZoneRecord.destroy({ id: ZR_INTREE.id }) + await Zone.destroy({ id: Z_OUTSIDE.id }) + await Zone.destroy({ id: Z_INTREE.id }) + for (const u of [U_NOSELF, U_LIMITED, U_FULL]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { + await Group.destroy({ id: g.id }) + } + // Clean up subgroup entries + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + await Mysql.disconnect() +}) + +describe('checkPermission', () => { + describe('create actions', () => { + it('allows create when user has permission', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'create', undefined, + ) + assert.equal(r.allowed, true) + }) + + it('allows create into child group', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'create', undefined, + { targetGroupId: 4201 }, + ) + assert.equal(r.allowed, true) + }) + + it('denies create when user lacks permission', async () => { + const r = await Authz.checkPermission( + credsLimited, 'zone', 'create', undefined, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to create/) + }) + + it('denies create when target group not in tree', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'create', undefined, + { targetGroupId: 4202 }, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + }) + + describe('self-user restrictions', () => { + it('denies delete self', async () => { + const r = await Authz.checkPermission( + credsFull, 'user', 'delete', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to delete self/) + }) + + it('allows write self when self_write=true', async () => { + const r = await Authz.checkPermission( + credsFull, 'user', 'write', 4200, + ) + assert.equal(r.allowed, true) + }) + + it('denies write self when self_write=false', async () => { + const r = await Authz.checkPermission( + credsNoself, 'user', 'write', 4202, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to modify self/) + }) + + it('allows read self', async () => { + const r = await Authz.checkPermission( + credsFull, 'user', 'read', 4200, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('own-group restrictions', () => { + it('denies write to own group', async () => { + const r = await Authz.checkPermission( + credsFull, 'group', 'write', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to edit your own group/) + }) + + it('denies delete own group', async () => { + const r = await Authz.checkPermission( + credsFull, 'group', 'delete', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to delete your own group/) + }) + }) + + describe('nameserver usable list', () => { + it('allows read of usable nameserver', async () => { + const r = await Authz.checkPermission( + credsFull, 'nameserver', 'read', 4200, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('group tree ownership', () => { + it('allows read of in-tree zone', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'read', 4200, + ) + assert.equal(r.allowed, true) + }) + + it('allows write of in-tree zone with permission', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'write', 4200, + ) + assert.equal(r.allowed, true) + }) + + it('denies write when user lacks action permission', async () => { + const r = await Authz.checkPermission( + credsLimited, 'zone', 'write', 4201, + ) + assert.equal(r.allowed, false) + }) + }) + + describe('delegation access', () => { + it('allows read of delegated zone', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'read', 4201, + ) + assert.equal(r.allowed, true) + }) + + it('allows write of delegated zone when perm_write=1', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'write', 4201, + ) + assert.equal(r.allowed, true) + }) + + it('denies delete of delegated zone when perm_delete=0', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'delete', 4201, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /no 'delete' permission/) + }) + + it('allows delegate action when perm_delegate=1', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'delegate', 4201, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('pseudo-delegation (zone record via parent zone)', () => { + it('allows read of zone record in delegated zone', async () => { + const r = await Authz.checkPermission( + credsFull, 'zonerecord', 'read', 4201, + ) + assert.equal(r.allowed, true) + }) + }) + + describe('direct zone record delegation', () => { + it('allows read of directly delegated zone record', async () => { + const r = await Authz.checkPermission( + credsFull, 'zonerecord', 'read', 4202, + ) + assert.equal(r.allowed, true) + }) + + it('denies delete when perm_delete=0', async () => { + const r = await Authz.checkPermission( + credsFull, 'zonerecord', 'delete', 4202, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /no 'delete' permission/) + }) + }) + + describe('deny fallthrough', () => { + it('denies access to object not in tree and not delegated', async () => { + const r = await Authz.checkPermission( + credsLimited, 'zone', 'read', 4200, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + + it('denies when object does not exist', async () => { + const r = await Authz.checkPermission( + credsFull, 'zone', 'read', 99999, + ) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + }) +}) + +describe('getObjectGroupId', () => { + it('returns group id for zone', async () => { + assert.equal(await Authz.getObjectGroupId('zone', 4200), 4200) + }) + + it('returns group id for zonerecord via join', async () => { + assert.equal(await Authz.getObjectGroupId('zonerecord', 4200), 4200) + }) + + it('returns group id for user', async () => { + assert.equal(await Authz.getObjectGroupId('user', 4200), 4200) + }) + + it('returns group id for nameserver', async () => { + assert.equal(await Authz.getObjectGroupId('nameserver', 4200), 4200) + }) + + it('returns parent_group_id for group', async () => { + assert.equal(await Authz.getObjectGroupId('group', 4201), 4200) + }) + + it('returns 1 for root group', async () => { + assert.equal(await Authz.getObjectGroupId('group', 4200), 1) + }) + + it('returns null for unknown resource type', async () => { + assert.equal(await Authz.getObjectGroupId('bogus', 4200), null) + }) + + it('returns null for nonexistent object', async () => { + assert.equal(await Authz.getObjectGroupId('zone', 99999), null) + }) +}) + +describe('isInGroupTree', () => { + it('returns true for same group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4200), true) + }) + + it('returns true for child group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4201), true) + }) + + it('returns false for unrelated group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4202), false) + }) + + it('returns false for parent from child perspective', async () => { + assert.equal(await Authz.isInGroupTree(4201, 4200), false) + }) +}) + +describe('getDelegateAccess', () => { + it('returns delegation row for directly delegated zone', async () => { + const d = await Authz.getDelegateAccess(4200, 4201, 'zone') + assert.ok(d) + assert.equal(d.perm_write, 1) + assert.equal(d.perm_delete, 0) + }) + + it('returns null for non-delegated zone', async () => { + const d = await Authz.getDelegateAccess(4202, 4200, 'zone') + assert.equal(d, null) + }) + + it('returns pseudo-delegation for zone record via parent zone', async () => { + const d = await Authz.getDelegateAccess(4200, 4201, 'zonerecord') + assert.ok(d) + assert.equal(d.pseudo, 1) + }) + + it('returns direct delegation for zone record', async () => { + const d = await Authz.getDelegateAccess(4200, 4202, 'zonerecord') + assert.ok(d) + assert.equal(d.perm_write, 1) + assert.equal(d.perm_delete, 0) + }) + + it('returns null for unknown resource type', async () => { + const d = await Authz.getDelegateAccess(4200, 4200, 'bogus') + assert.equal(d, null) + }) +}) + +describe('capPermissions', () => { + it('removes fields user lacks permission for', () => { + const userPerm = { + zone: { create: true, write: false, delete: true }, + user: { create: false }, + } + const target = { + zone_create: 1, + zone_write: 1, + zone_delete: 1, + user_create: 1, + } + const capped = Authz.capPermissions(userPerm, target) + assert.equal(capped.zone_create, 1) + assert.equal(capped.zone_write, undefined) + assert.equal(capped.zone_delete, 1) + assert.equal(capped.user_create, undefined) + }) + + it('returns null/undefined inputs as-is', () => { + assert.equal(Authz.capPermissions({}, null), null) + assert.equal(Authz.capPermissions({}, undefined), undefined) + }) +}) diff --git a/lib/delegation.js b/lib/delegation.js new file mode 100644 index 0000000..cf02433 --- /dev/null +++ b/lib/delegation.js @@ -0,0 +1,203 @@ +import Mysql from './mysql.js' + +const TYPE_META = { + ZONE: { table: 'nt_zone', idCol: 'nt_zone_id' }, + ZONERECORD: { table: 'nt_zone_record', idCol: 'nt_zone_record_id' }, + NAMESERVER: { table: 'nt_nameserver', idCol: 'nt_nameserver_id' }, + GROUP: { table: 'nt_group', idCol: 'nt_group_id' }, +} + +const PERM_FIELDS = [ + 'perm_write', + 'perm_delete', + 'perm_delegate', + 'zone_perm_add_records', + 'zone_perm_delete_records', +] + +class Delegation { + constructor() { + this.mysql = Mysql + } + + async create(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT nt_group_id FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length > 0) return { duplicate: true } + + const row = { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + delegated_by_id: args.delegated_by_id ?? 0, + delegated_by_name: args.delegated_by_name ?? '', + } + + for (const f of PERM_FIELDS) { + row[f] = args[f] === false ? 0 : 1 + } + + await Mysql.execute(...Mysql.insert('nt_delegate', row)) + + await this.log(row, 'delegated') + + return { created: true } + } + + async get(args) { + const { gid, oid, type } = args + const objType = type ?? 'ZONE' + const meta = TYPE_META[objType] + if (!meta) return [] + + if (oid !== undefined) { + return this.getDelegates(oid, objType) + } + if (gid !== undefined) { + return this.getDelegated(gid, objType, meta) + } + return [] + } + + async getDelegated(gid, objType, meta) { + const query = `SELECT + d.nt_group_id, + d.nt_object_id, + d.nt_object_type, + g.name AS group_name, + d.delegated_by_id, + d.delegated_by_name, + d.perm_write AS delegate_write, + d.perm_delete AS delegate_delete, + d.perm_delegate AS delegate_delegate, + d.zone_perm_add_records AS delegate_add_records, + d.zone_perm_delete_records AS delegate_delete_records, + o.${meta.idCol} AS ${meta.idCol} + FROM nt_delegate d + JOIN ${meta.table} o ON o.${meta.idCol} = d.nt_object_id + JOIN nt_group g ON g.nt_group_id = d.nt_group_id + WHERE d.nt_object_type = ? + AND d.nt_group_id = ? + AND d.deleted = 0 + AND o.deleted = 0` + + return Mysql.execute(query, [objType, gid]) + } + + async getDelegates(oid, objType) { + const query = `SELECT + d.nt_group_id, + d.nt_object_id, + d.nt_object_type, + g.name AS group_name, + d.delegated_by_id, + d.delegated_by_name, + d.perm_write AS delegate_write, + d.perm_delete AS delegate_delete, + d.perm_delegate AS delegate_delegate, + d.zone_perm_add_records AS delegate_add_records, + d.zone_perm_delete_records AS delegate_delete_records + FROM nt_delegate d + JOIN nt_group g ON g.nt_group_id = d.nt_group_id + WHERE d.nt_object_type = ? + AND d.nt_object_id = ? + AND d.deleted = 0` + + return Mysql.execute(query, [objType, oid]) + } + + async put(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT nt_group_id FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length === 0) return null + + const updates = {} + for (const f of PERM_FIELDS) { + if (args[f] !== undefined) { + updates[f] = args[f] === true ? 1 : 0 + } + } + + if (Object.keys(updates).length === 0) return true + + const setClauses = Object.keys(updates) + .map((k) => `${k} = ?`) + .join(', ') + const values = [...Object.values(updates), gid, oid, type] + + await Mysql.execute( + `UPDATE nt_delegate SET ${setClauses} + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + values, + ) + + await this.log( + { nt_group_id: gid, nt_object_id: oid, nt_object_type: type, ...updates }, + 'modified', + ) + + return true + } + + async delete(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT nt_group_id, perm_write, perm_delete, perm_delegate, + zone_perm_add_records, zone_perm_delete_records + FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length === 0) return null + + await this.log( + { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + ...existing[0], + }, + 'deleted', + ) + + await Mysql.execute( + `DELETE FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ?`, + [gid, oid, type], + ) + + return true + } + + async log(data, action) { + const row = { + nt_user_id: data.delegated_by_id ?? 0, + nt_user_name: data.delegated_by_name ?? '', + action, + nt_object_type: data.nt_object_type, + nt_object_id: data.nt_object_id, + nt_group_id: data.nt_group_id, + timestamp: Math.floor(Date.now() / 1000), + perm_write: data.perm_write ?? 1, + perm_delete: data.perm_delete ?? 1, + perm_delegate: data.perm_delegate ?? 1, + zone_perm_add_records: data.zone_perm_add_records ?? 1, + zone_perm_delete_records: data.zone_perm_delete_records ?? 1, + } + + await Mysql.execute(...Mysql.insert('nt_delegate_log', row)) + } +} + +export default new Delegation() diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index 3f1a214..1ad8828 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -55,7 +55,7 @@ class Group extends GroupBase { async get(args_orig) { const args = JSON.parse(JSON.stringify(args_orig)) - if (args.deleted === undefined) args.deleted = false + if (args.deleted === undefined && !args.id) args.deleted = false const include_subgroups = args.include_subgroups === true delete args.include_subgroups @@ -112,7 +112,7 @@ class Group extends GroupBase { for (const b of boolFields) { row[b] = row[b] === 1 } - if ([false, undefined].includes(args_orig.deleted)) delete row.deleted + if (args_orig.deleted === false) delete row.deleted const perm = await Permission.get({ gid: row.id }) if (perm) { diff --git a/lib/nameserver/store/mysql.js b/lib/nameserver/store/mysql.js index 2b04a20..8a1496f 100644 --- a/lib/nameserver/store/mysql.js +++ b/lib/nameserver/store/mysql.js @@ -33,7 +33,9 @@ class Nameserver extends NameserverBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + const origDeleted = args.deleted + if (args.deleted === undefined && !args.id) args.deleted = false + if (args.deleted === undefined) delete args.deleted if (args.name !== undefined) { args['ns.name'] = args.name @@ -65,7 +67,7 @@ class Nameserver extends NameserverBase { for (const b of boolFields) { row[b] = row[b] === 1 } - if (args.deleted === false) delete row.deleted + if (origDeleted === false) delete row.deleted } return dbToObject(rows) } @@ -74,9 +76,21 @@ class Nameserver extends NameserverBase { if (!args.id) return false const id = args.id delete args.id - // Mysql.debug(1) + + if (args.export?.type) { + const rows = await Mysql.execute( + ...Mysql.select('SELECT id FROM nt_nameserver_export_type', { + name: args.export.type, + }), + ) + if (rows.length > 0) args.export_type_id = rows[0].id + delete args.export.type + } + + const dbArgs = mapToDbColumn(objectToDb(args), nsDbMap) + if (Object.keys(dbArgs).length === 0) return true const r = await Mysql.execute( - ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, mapToDbColumn(args, nsDbMap)), + ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, dbArgs), ) return r.changedRows === 1 } diff --git a/lib/permission/index.js b/lib/permission/index.js index ef3883f..4460f8c 100644 --- a/lib/permission/index.js +++ b/lib/permission/index.js @@ -86,7 +86,8 @@ class Permission { AND p.deleted=${args.deleted === true ? 1 : 0} AND u.deleted=0 AND u.nt_user_id=?` - const rows = await Mysql.execute(...Mysql.select(query, [args.uid])) + const [q, p] = Mysql.select(query, [args.uid]) + const rows = await Mysql.execute(q, p) if (rows.length === 0) return const row = dbToObject(rows[0]) if ([false, undefined].includes(args.deleted)) delete row.deleted @@ -97,6 +98,9 @@ class Permission { if (!args.id) return false const id = args.id delete args.id + if (Array.isArray(args.usable_ns)) { + args.usable_ns = args.usable_ns.join(',') + } const r = await Mysql.execute( ...Mysql.update(`nt_perm`, `nt_perm_id=${id}`, mapToDbColumn(args, permDbMap)), ) @@ -126,7 +130,8 @@ class Permission { async getEffective(uid) { const userPerm = await this.get({ uid }) if (userPerm && userPerm.inherit === false) return userPerm - return this.getGroup({ uid }) + const groupPerm = await this.getGroup({ uid }) + return groupPerm } /** @@ -248,9 +253,11 @@ function dbToObject(row) { row.user.id = row.uid delete row.uid } - if (row.gid !== undefined) { + if (row.gid !== undefined && row.gid !== null) { row.group.id = row.gid delete row.gid + } else { + delete row.gid } row.nameserver.usable = [] if (![undefined, null, ''].includes(row.usable_ns)) { diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 88db192..8e3013e 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -33,7 +33,8 @@ class UserRepoMySQL extends UserBase { AND g.deleted=0 AND u.deleted=0 AND u.username = ? - AND g.name = ?` + AND g.name = ? + ORDER BY u.nt_user_id DESC` for (const u of await Mysql.execute(query, [username, groupName])) { if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) { @@ -55,8 +56,10 @@ class UserRepoMySQL extends UserBase { } async create(args) { - const u = await this.get({ id: args.id, gid: args.gid }) - if (u.length === 1) return u[0].id + if (args.id) { + const u = await this.get({ id: args.id }) + if (u.length === 1) return u[0].id + } args = JSON.parse(JSON.stringify(args)) @@ -74,6 +77,7 @@ class UserRepoMySQL extends UserBase { if (userId && inherit === false) { await Permission.create({ uid: userId, + gid: args.gid, inherit: false, name: `User ${args.username} perms`, }) @@ -85,7 +89,7 @@ class UserRepoMySQL extends UserBase { async get(args) { const origDeleted = args.deleted // capture before defaulting/removing args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + if (args.deleted === undefined && !args.id) args.deleted = false const include_subgroups = args.include_subgroups === true delete args.include_subgroups @@ -146,7 +150,7 @@ class UserRepoMySQL extends UserBase { for (const b of boolFields) { r[b] = r[b] === 1 } - if ([false, undefined].includes(origDeleted)) delete r.deleted + if (origDeleted === false) delete r.deleted const effectivePerm = await Permission.getEffective(r.id) if (effectivePerm) { @@ -176,6 +180,7 @@ class UserRepoMySQL extends UserBase { const [userData] = await this.get({ id }) await Permission.create({ uid: id, + gid: userData.gid, inherit: false, name: `User ${userData.username} perms`, }) diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index de30161..214f0b1 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -52,7 +52,9 @@ class ZoneRepoMySQL extends ZoneBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - args.deleted = args.deleted ?? false + const origDeleted = args.deleted + if (args.deleted === undefined && !args.id) args.deleted = false + if (args.deleted === undefined) delete args.deleted const filters = { search: args.search, @@ -128,7 +130,7 @@ class ZoneRepoMySQL extends ZoneBase { if (row['last_publish'] === undefined) delete row['last_publish'] if (/00:00:00/.test(row['last_publish'])) row['last_publish'] = null - if (args.deleted === false) delete row.deleted + if (origDeleted === false) delete row.deleted } return rows @@ -162,9 +164,16 @@ class ZoneRepoMySQL extends ZoneBase { if (!args.id) return false const id = args.id delete args.id + const dbArgs = mapToDbColumn(args, zoneDbMap) const r = await Mysql.execute( - ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), + ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, dbArgs), ) + if (r.changedRows > 0) { + await Mysql.execute( + 'UPDATE nt_zone SET serial = serial + 1 WHERE nt_zone_id = ?', + [id], + ) + } return r.changedRows === 1 } diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index c56f832..2d48958 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -21,7 +21,10 @@ class ZoneRecordMySQL extends ZoneRecordBase { if (g.length === 1) return g[0].id } - const rrArgs = args.ttl === undefined ? { ...args, default: { ttl: 0 } } : args + const qualified = await this.qualifyOwner({ ...args }) + const rrArgs = qualified.ttl === undefined + ? { ...qualified, default: { ttl: 0 } } + : qualified new RR[args.type](rrArgs) args = objectToDb(args) @@ -31,7 +34,9 @@ class ZoneRecordMySQL extends ZoneRecordBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + const origDeleted = args.deleted + if (args.deleted === undefined && !args.id) args.deleted = false + if (args.deleted === undefined) delete args.deleted if (args.type !== undefined) { args.type_id = RR.typeMap[args.type] delete args.type @@ -61,7 +66,7 @@ class ZoneRecordMySQL extends ZoneRecordBase { for (const b of boolFields) { row[b] = row[b] === 1 } - if (args.deleted === false) delete row.deleted + if (origDeleted === false) delete row.deleted } const zrObjects = dbToObject(rows) @@ -73,6 +78,10 @@ class ZoneRecordMySQL extends ZoneRecordBase { if (!args.id) return false const id = args.id delete args.id + if (args.type) { + args.type_id = RR.typeMap[args.type] + delete args.type + } const r = await Mysql.execute( ...Mysql.update(`nt_zone_record`, `nt_zone_record_id=${id}`, mapToDbColumn(args, zrDbMap)), ) @@ -92,6 +101,20 @@ class ZoneRecordMySQL extends ZoneRecordBase { const r = await Mysql.execute(...Mysql.delete(`nt_zone_record`, { nt_zone_record_id: args.id })) return r.affectedRows === 1 } + + async qualifyOwner(args) { + if (!args.owner || args.owner.endsWith('.') || !args.zid) return args + const rows = await Mysql.execute( + 'SELECT zone FROM nt_zone WHERE nt_zone_id = ?', + [args.zid], + ) + if (rows.length === 0) return args + const zone = rows[0].zone.endsWith('.') + ? rows[0].zone + : `${rows[0].zone}.` + args.owner = `${args.owner}.${zone}` + return args + } } export default ZoneRecordMySQL diff --git a/routes/authz.test.js b/routes/authz.test.js new file mode 100644 index 0000000..9122b6c --- /dev/null +++ b/routes/authz.test.js @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict' +import { describe, it, before, after } from 'node:test' + +import { init } from './index.js' +import Group from '../lib/group/index.js' +import User from '../lib/user/index.js' +import Zone from '../lib/zone.js' +import ZoneRecord from '../lib/zone_record.js' +import Nameserver from '../lib/nameserver.js' +import Permission from '../lib/permission.js' +import Delegation from '../lib/delegation.js' +import Mysql from '../lib/mysql.js' + +const G_ROOT = { + id: 4200, + parent_gid: 0, + name: 'authz-root', +} +const G_CHILD = { + id: 4201, + parent_gid: 4200, + name: 'authz-child', +} +const G_OUTSIDE = { + id: 4202, + parent_gid: 0, + name: 'authz-outside', +} + +const PASSWORD = 'Wh@tA-Decent#P6ssw0rd' + +const U_FULL = { + id: 4200, + gid: 4200, + username: 'authz-full', + email: 'authz-full@example.com', + password: PASSWORD, + first_name: 'Full', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_LIMITED = { + id: 4201, + gid: 4202, + username: 'authz-limited', + email: 'authz-limited@example.com', + password: PASSWORD, + first_name: 'Limited', + last_name: 'Perm', + inherit_group_permissions: false, +} + +const Z_INTREE = { + id: 4200, + gid: 4200, + zone: 'authz.example.com.', + mailaddr: 'hostmaster.authz.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const Z_OUTSIDE = { + id: 4201, + gid: 4202, + zone: 'authz-out.example.com.', + mailaddr: 'hostmaster.authz-out.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} + +const ZR_INTREE = { + id: 4200, + zid: 4200, + owner: 'test.authz.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const ZR_OUTSIDE = { + id: 4201, + zid: 4201, + owner: 'test.authz-out.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} + +const NS = { + id: 4200, + gid: 4200, + name: 'ns1.authz.example.com.', + ttl: 3600, + description: 'authz test ns', + address: '192.0.2.10', + export: { type: 'bind', interval: 0, serials: 0 }, +} + +let server +const authFull = { headers: {} } +const authLimited = { headers: {} } + +before(async () => { + // Clean up stale data from prior crashed runs + try { await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) } + catch { /* ignore */ } + for (const id of [4200, 4201]) { + await ZoneRecord.destroy({ id }) + await Zone.destroy({ id }) + } + await Nameserver.destroy({ id: 4200 }) + for (const id of [4200, 4201]) { + const p = await Permission.get({ uid: id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id }) + } + for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + + for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) + for (const u of [U_FULL, U_LIMITED]) await User.create(u) + + // Full permissions for user 4200 + const fullPerm = await Permission.get({ uid: U_FULL.id }) + if (fullPerm) { + await Permission.put({ + id: fullPerm.id, + self_write: 1, + group_write: 1, group_create: 1, group_delete: 1, + zone_write: 1, zone_create: 1, zone_delete: 1, zone_delegate: 1, + zonerecord_write: 1, zonerecord_create: 1, zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, user_create: 1, user_delete: 1, + nameserver_write: 1, nameserver_create: 1, nameserver_delete: 1, + usable_ns: '4200', + }) + } + + // No permissions for user 4201 + const limPerm = await Permission.get({ uid: U_LIMITED.id }) + if (limPerm) { + await Permission.put({ + id: limPerm.id, + self_write: 0, + group_write: 0, group_create: 0, group_delete: 0, + zone_write: 0, zone_create: 0, zone_delete: 0, zone_delegate: 0, + zonerecord_write: 0, zonerecord_create: 0, zonerecord_delete: 0, + zonerecord_delegate: 0, + user_write: 0, user_create: 0, user_delete: 0, + nameserver_write: 0, nameserver_create: 0, nameserver_delete: 0, + usable_ns: '', + }) + } + + await Zone.create(Z_INTREE) + await Zone.create(Z_OUTSIDE) + await ZoneRecord.create(ZR_INTREE) + await ZoneRecord.create(ZR_OUTSIDE) + await Nameserver.create(NS) + + // Delegation: zone 4201 → group 4200, write=yes delete=no + await Delegation.create({ + gid: 4200, oid: 4201, type: 'ZONE', + perm_write: true, perm_delete: false, perm_delegate: true, + }) + + server = await init() + + // Login full-perm user + const r1 = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${U_FULL.username}@${G_ROOT.name}`, + password: PASSWORD, + }, + }) + assert.equal(r1.statusCode, 200, `full login failed: ${JSON.stringify(r1.result)}`) + authFull.headers = { + Authorization: `Bearer ${r1.result.session.token}`, + } + + // Login limited user + const r2 = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${U_LIMITED.username}@${G_OUTSIDE.name}`, + password: PASSWORD, + }, + }) + assert.equal(r2.statusCode, 200, `limited login failed: ${JSON.stringify(r2.result)}`) + authLimited.headers = { + Authorization: `Bearer ${r2.result.session.token}`, + } +}) + +after(async () => { + await server.stop() + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + await Nameserver.destroy({ id: NS.id }) + await ZoneRecord.destroy({ id: ZR_OUTSIDE.id }) + await ZoneRecord.destroy({ id: ZR_INTREE.id }) + await Zone.destroy({ id: Z_OUTSIDE.id }) + await Zone.destroy({ id: Z_INTREE.id }) + for (const u of [U_LIMITED, U_FULL]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { + await Group.destroy({ id: g.id }) + } + await Mysql.execute( + 'DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (?, ?, ?)', + [4200, 4201, 4202], + ) + await Mysql.disconnect() +}) + +describe('authz plugin - zone routes', () => { + it('200 for GET /zone/{id} with full-perm user (in-tree)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('200 for GET /zone/{id} with full-perm user (delegated)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for GET /zone/{id} with limited user (out of tree)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + assert.ok(res.result.error_code) + }) + + it('200 for GET /zone (list, no per-object check)', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone', + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for POST /zone when user lacks zone.create', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: authLimited.headers, + payload: { + gid: 4202, + zone: 'denied.example.com.', + mailaddr: 'hostmaster.denied.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, + }, + }) + assert.equal(res.statusCode, 403) + }) + + it('200 for PUT /zone/{id} with full-perm user', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { ttl: 7200 }, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for DELETE /zone/{id} with delegated perm_delete=0', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) +}) + +describe('authz plugin - user self-ops', () => { + it('403 for DELETE /user/{self}', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to delete self/) + }) + + it('403 for PUT /user/{self} when self_write=false', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_LIMITED.id}`, + headers: authLimited.headers, + payload: { first_name: 'Nope' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to modify self/) + }) +}) + +describe('authz plugin - group self-ops', () => { + it('403 for PUT /group/{own-group}', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_ROOT.id}`, + headers: authFull.headers, + payload: { name: 'nope' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to edit your own group/) + }) + + it('403 for DELETE /group/{own-group}', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/group/${G_ROOT.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + assert.match( + res.result.error_msg, + /Not allowed to delete your own group/, + ) + }) +}) + +describe('authz plugin - zone record delegation', () => { + it('200 for GET /zone_record/{id} via pseudo-delegation', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone_record/${ZR_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for GET /zone_record/{id} with limited user', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone_record/${ZR_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + }) +}) diff --git a/routes/delegation.js b/routes/delegation.js new file mode 100644 index 0000000..b24991e --- /dev/null +++ b/routes/delegation.js @@ -0,0 +1,216 @@ +import validate from '@nictool/validate' + +import Delegation from '../lib/delegation.js' +import Permission from '../lib/permission.js' +import { meta } from '../lib/util.js' + +const DELEG_PERM_CAP = { + ZONE: { + perm_write: ['zone', 'write'], + perm_delete: ['zone', 'delete'], + perm_delegate: ['zone', 'delegate'], + zone_perm_add_records: ['zonerecord', 'create'], + zone_perm_delete_records: ['zonerecord', 'delete'], + }, + ZONERECORD: { + perm_write: ['zonerecord', 'write'], + perm_delete: ['zonerecord', 'delete'], + perm_delegate: ['zonerecord', 'delegate'], + }, +} + +function capDelegationPerms(payload, perm, mode) { + const capMap = DELEG_PERM_CAP[payload.type] + if (!capMap) return + for (const [field, [resource, action]] of Object.entries(capMap)) { + if (payload[field] === undefined) continue + if (perm[resource]?.[action] !== true) { + if (mode === 'create') payload[field] = false + else delete payload[field] + } + } +} + +function DelegationRoutes(server) { + server.route([ + { + method: 'GET', + path: '/delegation', + options: { + validate: { + query: validate.delegation.GET_req, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const getArgs = {} + if (request.query.gid !== undefined) getArgs.gid = request.query.gid + if (request.query.oid !== undefined) getArgs.oid = request.query.oid + if (request.query.type !== undefined) getArgs.type = request.query.type + + const delegation = await Delegation.get(getArgs) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `here are your delegations`, + }, + }) + .code(200) + }, + }, + { + method: 'POST', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'payload.oid' } }, + validate: { + payload: validate.delegation.POST, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const { user } = request.auth.credentials + const perm = await Permission.getEffective(user.id) + capDelegationPerms(request.payload, perm, 'create') + + const result = await Delegation.create(request.payload) + + if (result.duplicate) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `that delegation already exists`, + }, + }) + .code(409) + } + + const delegation = await Delegation.get({ + gid: request.payload.gid, + oid: request.payload.oid, + type: request.payload.type, + }) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `the delegation was created`, + }, + }) + .code(201) + }, + }, + { + method: 'PUT', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'payload.oid' } }, + validate: { + payload: validate.delegation.PUT, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const { user } = request.auth.credentials + const perm = await Permission.getEffective(user.id) + capDelegationPerms(request.payload, perm, 'edit') + + const result = await Delegation.put(request.payload) + + if (result === null) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I couldn't find that delegation`, + }, + }) + .code(404) + } + + const delegation = await Delegation.get({ + gid: request.payload.gid, + oid: request.payload.oid, + type: request.payload.type, + }) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `the delegation was updated`, + }, + }) + .code(200) + }, + }, + { + method: 'DELETE', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'query.oid' } }, + validate: { + query: validate.delegation.DELETE, + failAction: 'log', + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const args = { + gid: request.query.gid, + oid: request.query.oid, + type: request.query.type, + } + + const result = await Delegation.delete(args) + + if (result === null) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I couldn't find that delegation`, + }, + }) + .code(404) + } + + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I deleted that delegation`, + }, + }) + .code(200) + }, + }, + ]) +} + +export default DelegationRoutes + +export { Delegation, DelegationRoutes } diff --git a/routes/group.js b/routes/group.js index c9df9d1..a94ff5d 100644 --- a/routes/group.js +++ b/routes/group.js @@ -1,8 +1,30 @@ import validate from '@nictool/validate' import Group from '../lib/group/index.js' +import Authz from '../lib/authz.js' +import Permission from '../lib/permission.js' import { meta } from '../lib/util.js' +const PERM_FIELDS = new Set([ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', + 'self_write', 'usable_ns', +]) + +function extractPermFields(payload) { + const permFields = {} + for (const key of Object.keys(payload)) { + if (PERM_FIELDS.has(key)) { + permFields[key] = payload[key] + delete payload[key] + } + } + return permFields +} + function GroupRoutes(server) { server.route([ { @@ -36,20 +58,25 @@ function GroupRoutes(server) { method: 'GET', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.group.GET_req, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { - const groups = await Group.get({ - deleted: request.query.deleted ?? 0, + const getArgs = { id: parseInt(request.params.id, 10), include_subgroups: request.query.include_subgroups === true, - }) + } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } + const groups = await Group.get(getArgs) if (groups.length !== 1 && !request.query.include_subgroups) { return h @@ -77,17 +104,30 @@ function GroupRoutes(server) { method: 'POST', path: '/group', options: { + app: { permission: { resource: 'group', action: 'create' } }, validate: { payload: validate.group.POST, + options: { allowUnknown: true }, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) const gid = await Group.create(request.payload) + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ gid }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } + const groups = await Group.get({ id: gid }) return h @@ -105,16 +145,29 @@ function GroupRoutes(server) { method: 'PUT', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.group.PUT, + options: { allowUnknown: true }, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ gid: id }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } + await Group.put({ ...request.payload, id }) const groups = await Group.get({ id }) @@ -134,6 +187,7 @@ function GroupRoutes(server) { method: 'DELETE', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.group.DELETE, }, diff --git a/routes/index.js b/routes/index.js index 16f4907..c873a87 100644 --- a/routes/index.js +++ b/routes/index.js @@ -24,6 +24,8 @@ import { PermissionRoutes } from './permission.js' import { NameserverRoutes } from './nameserver.js' import { ZoneRoutes } from './zone.js' import { ZoneRecordRoutes } from './zone_record.js' +import { DelegationRoutes } from './delegation.js' +import authzPlugin from '../lib/authz-plugin.js' let server @@ -105,6 +107,8 @@ async function setup() { server.auth.default('nt_jwt_strategy') + await server.register(authzPlugin) + server.route({ method: 'GET', path: '/', @@ -120,6 +124,7 @@ async function setup() { NameserverRoutes(server) ZoneRoutes(server) ZoneRecordRoutes(server) + DelegationRoutes(server) server.route({ method: '*', diff --git a/routes/nameserver.js b/routes/nameserver.js index ec2d13a..3e352fe 100644 --- a/routes/nameserver.js +++ b/routes/nameserver.js @@ -18,8 +18,9 @@ function NameserverRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const getArgs = { - deleted: request.query.deleted === true ? 1 : 0, + const getArgs = {} + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) if (request.query.gid) getArgs.gid = parseInt(request.query.gid, 10) @@ -41,6 +42,7 @@ function NameserverRoutes(server) { method: 'POST', path: '/nameserver', options: { + app: { permission: { resource: 'nameserver', action: 'create' } }, validate: { payload: validate.nameserver.POST, }, @@ -69,6 +71,7 @@ function NameserverRoutes(server) { method: 'PUT', path: '/nameserver/{id}', options: { + app: { permission: { resource: 'nameserver', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.nameserver.PUT, }, @@ -100,6 +103,7 @@ function NameserverRoutes(server) { method: 'DELETE', path: '/nameserver/{id}', options: { + app: { permission: { resource: 'nameserver', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.nameserver.DELETE, }, diff --git a/routes/permission.js b/routes/permission.js index b06f537..21e3753 100644 --- a/routes/permission.js +++ b/routes/permission.js @@ -64,6 +64,38 @@ function PermissionRoutes(server) { .code(201) }, }, + { + method: 'PUT', + path: '/permission/{id}', + options: { + validate: { + payload: validate.permission.POST, + }, + response: { + schema: validate.permission.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const id = parseInt(request.params.id, 10) + const existing = await Permission.get({ id }) + if (!existing) { + return h + .response({ meta: { api: meta.api, msg: `permission not found` } }) + .code(404) + } + + await Permission.put({ ...request.payload, id }) + const permission = await Permission.get({ id }) + + return h + .response({ + permission, + meta: { api: meta.api, msg: `permission updated` }, + }) + .code(200) + }, + }, { method: 'DELETE', path: '/permission/{id}', diff --git a/routes/session.js b/routes/session.js index 610bbc3..cbc0cfe 100644 --- a/routes/session.js +++ b/routes/session.js @@ -5,6 +5,7 @@ import Jwt from '@hapi/jwt' import User from '../lib/user/index.js' import Session from '../lib/session.js' +import Permission from '../lib/permission.js' import { meta } from '../lib/util.js' @@ -18,6 +19,7 @@ function SessionRoutes(server) { options: { response: { schema: validate.session.GET_res, + options: { allowUnknown: true }, }, tags: ['api'], }, @@ -26,11 +28,20 @@ function SessionRoutes(server) { Session.put({ id: session.id, last_access: true }) + const perm = await Permission.getEffective(user.id) + const groupPerm = await Permission.getGroup({ + uid: user.id, deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: user, group: group, session: { id: session.id }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `working on it`, @@ -49,6 +60,7 @@ function SessionRoutes(server) { }, response: { schema: validate.session.GET_res, + options: { allowUnknown: true }, }, tags: ['api'], }, @@ -83,11 +95,20 @@ function SessionRoutes(server) { }, ) + const perm = await Permission.getEffective(account.user.id) + const groupPerm = await Permission.getGroup({ + uid: account.user.id, deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: account.user, group: account.group, session: { id: sessId, token: token }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `you are logged in`, diff --git a/routes/user.js b/routes/user.js index 04311ea..989582d 100644 --- a/routes/user.js +++ b/routes/user.js @@ -1,8 +1,30 @@ import validate from '@nictool/validate' import User from '../lib/user/index.js' +import Authz from '../lib/authz.js' +import Permission from '../lib/permission.js' import { meta } from '../lib/util.js' +const PERM_FIELDS = new Set([ + 'group_write', 'group_create', 'group_delete', + 'zone_write', 'zone_create', 'zone_delegate', 'zone_delete', + 'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete', + 'user_write', 'user_create', 'user_delete', + 'nameserver_write', 'nameserver_create', 'nameserver_delete', + 'self_write', 'usable_ns', +]) + +function extractPermFields(payload) { + const permFields = {} + for (const key of Object.keys(payload)) { + if (PERM_FIELDS.has(key)) { + permFields[key] = payload[key] + delete payload[key] + } + } + return permFields +} + function UserRoutes(server) { server.route([ { @@ -44,19 +66,22 @@ function UserRoutes(server) { method: 'GET', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.user.GET_req, }, response: { schema: validate.user.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { - const users = await User.get({ - deleted: request.query.deleted ?? 0, - id: parseInt(request.params.id, 10), - }) + const getArgs = { id: parseInt(request.params.id, 10) } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } + const users = await User.get(getArgs) if (users.length !== 1) { return h @@ -69,13 +94,23 @@ function UserRoutes(server) { .code(204) } + const uid = getArgs.id const gid = parseInt(users[0].gid, 10) delete users[0].gid + const perm = await Permission.getEffective(uid) + const groupPerm = await Permission.getGroup({ + uid, deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: users, group: { id: gid }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `here's your user`, @@ -88,18 +123,28 @@ function UserRoutes(server) { method: 'POST', path: '/user', options: { + app: { permission: { resource: 'user', action: 'create' } }, validate: { payload: validate.user.POST, + options: { allowUnknown: true }, }, response: { schema: validate.user.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) const uid = await User.create(request.payload) - if (!uid) { - console.log(`POST /user oops`) // TODO + + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ uid }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) } const users = await User.get({ id: uid }) @@ -122,16 +167,25 @@ function UserRoutes(server) { method: 'PUT', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.user.PUT, + options: { allowUnknown: true }, }, response: { schema: validate.user.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + const args = { ...request.payload, id } if (args.password) { @@ -141,6 +195,21 @@ function UserRoutes(server) { await User.put(args) + if (Object.keys(permFields).length > 0) { + let perm = await Permission.get({ uid: id }) + if (!perm) { + const [userData] = await User.get({ id }) + const permId = await Permission.create({ + uid: id, + gid: userData.gid, + inherit: false, + name: `User ${userData.username} perms`, + }) + perm = await Permission.get({ id: permId }) + } + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } + const users = await User.get({ id }) if (!users.length) { return h @@ -161,11 +230,13 @@ function UserRoutes(server) { method: 'DELETE', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.user.DELETE, }, response: { schema: validate.user.GET_res, + failAction: 'log', }, tags: ['api'], }, diff --git a/routes/zone.js b/routes/zone.js index 6d695d9..f4bbbb4 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -10,6 +10,7 @@ function ZoneRoutes(server) { method: 'GET', path: '/zone/{id?}', options: { + app: { permission: { resource: 'zone', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.zone.GET_req, }, @@ -19,11 +20,12 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const deleted = request.query.deleted === true const getArgs = { - deleted, limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) if (request.query.gid != null) { const gid = Number.isInteger(request.query.gid) @@ -38,6 +40,7 @@ function ZoneRoutes(server) { if (request.query.sort_by) getArgs.sort_by = request.query.sort_by if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir + const deleted = getArgs.deleted ?? false const countArgs = { deleted, ...(getArgs.id ? { id: getArgs.id } : {}), @@ -74,6 +77,7 @@ function ZoneRoutes(server) { method: 'POST', path: '/zone', options: { + app: { permission: { resource: 'zone', action: 'create' } }, validate: { payload: validate.zone.POST, }, @@ -102,8 +106,10 @@ function ZoneRoutes(server) { method: 'PUT', path: '/zone/{id}', options: { + app: { permission: { resource: 'zone', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.zone.PUT, + options: { allowUnknown: true }, }, response: { schema: validate.zone.GET_res, @@ -164,6 +170,7 @@ function ZoneRoutes(server) { method: 'DELETE', path: '/zone/{id}', options: { + app: { permission: { resource: 'zone', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.zone.DELETE, }, diff --git a/routes/zone_record.js b/routes/zone_record.js index 0ebf823..b05b67a 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -33,6 +33,7 @@ function ZoneRecordRoutes(server) { method: 'GET', path: '/zone_record/{id?}', options: { + app: { permission: { resource: 'zonerecord', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.zone_record.GET_req, }, @@ -43,8 +44,9 @@ function ZoneRecordRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const getArgs = { - deleted: request.query.deleted === true ? 1 : 0, + const getArgs = {} + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) if (request.query.zid) getArgs.zid = parseInt(request.query.zid, 10) @@ -66,6 +68,7 @@ function ZoneRecordRoutes(server) { method: 'POST', path: '/zone_record', options: { + app: { permission: { resource: 'zonerecord', action: 'create' } }, validate: { payload: validate.zone_record.POST, }, @@ -94,6 +97,7 @@ function ZoneRecordRoutes(server) { method: 'PUT', path: '/zone_record/{id}', options: { + app: { permission: { resource: 'zonerecord', action: 'write', idFrom: 'params.id' } }, validate: { payload: validate.zone_record.PUT, }, @@ -127,6 +131,7 @@ function ZoneRecordRoutes(server) { method: 'DELETE', path: '/zone_record/{id}', options: { + app: { permission: { resource: 'zonerecord', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.zone_record.DELETE, },