diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c75d7..725fdd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + NICTOOL_VALIDATE_SPEC: https://github.com/NicTool/validate/archive/0a09c61bd174cd75e5b5ec62c83e947c432be98d.tar.gz + jobs: lint: uses: NicTool/.github/.github/workflows/lint.yml@main @@ -43,6 +46,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-mac: @@ -63,6 +68,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-docker: @@ -100,4 +107,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: sh test/run.sh diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 695f13c..beaa42d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,11 +6,42 @@ on: push: branches: [main] +env: + NICTOOL_VALIDATE_SPEC: https://github.com/NicTool/validate/archive/0a09c61bd174cd75e5b5ec62c83e947c432be98d.tar.gz + jobs: coverage: - uses: NicTool/.github/.github/workflows/coverage.yml@main - secrets: inherit + runs-on: ubuntu-latest permissions: contents: read - with: - mysql: true + steps: + - name: Start MySQL + run: sudo /etc/init.d/mysql start + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} + - name: Initialize MySQL + run: sh sql/init-mysql.sh + - name: Measure each store + env: + NODE_ENV: cov + run: | + for store in json mysql; do + NICTOOL_DATA_STORE=$store npm run test:coverage:lcov + cp coverage/lcov.info coverage/lcov-$store.info + done + - name: codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov-json.info,./coverage/lcov-mysql.info + disable_search: true + fail_ci_if_error: true + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.github_token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3151a4c..419529b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +- audit: enforce route-specific queries and file-store exact matching +- session: validate qualified usernames and permission responses +- user: add subgroup group-name sorting and reject unknown writes +- group/zone: validate permission controls and zone serial updates +- authz: a group change needs the owning tree, delegates edit in place +- zone_record: sort by the legacy rdata columns +- session: a failed activity touch no longer rejects unhandled + ### [3.0.3] - 2026-07-27 - many updates for data stores and NS backends diff --git a/docker/Dockerfile b/docker/Dockerfile index 0fea311..16b7be5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,12 @@ FROM node:22-trixie-slim WORKDIR /app -COPY package*.json . +COPY package*.json ./ +ARG NICTOOL_VALIDATE_SPEC # --omit=dev is safe: tests use node:test (stdlib), devDeps are only eslint/prettier -RUN npm install --omit=dev +RUN npm install --omit=dev \ + && if [ -n "$NICTOOL_VALIDATE_SPEC" ]; then \ + npm install --omit=dev --no-save "$NICTOOL_VALIDATE_SPEC"; \ + fi COPY . . EXPOSE 3000 CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 505c3aa..60e55dc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,6 +24,8 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + NICTOOL_VALIDATE_SPEC: ${NICTOOL_VALIDATE_SPEC:-} ports: - '${API_PORT:-3000}:3000' depends_on: diff --git a/lib/audit.test.js b/lib/audit.test.js new file mode 100644 index 0000000..26483b9 --- /dev/null +++ b/lib/audit.test.js @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict' +import { after, before, describe, it } from 'node:test' + +import Audit from './audit/index.js' +import Group from './group/index.js' +import User from './user/index.js' +import Zone from './zone/index.js' +import ZoneRecord from './zone_record/index.js' + +const gid = 6190 +const zid = 6190 +const zrid = 6190 +const actor = { id: 6190 } +const zone = { + id: zid, + gid, + zone: 'audit.example.com.', + mailaddr: 'hostmaster.audit.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const record = { + id: zrid, + zid, + owner: 'www.audit.example.com.', + type: 'A', + address: '192.0.2.19', + ttl: 300, +} + +before(async () => { + await Audit.destroyByUser(actor.id) + await ZoneRecord.destroy({ id: zrid }) + await Zone.destroy({ id: zid }) + await User.destroy({ id: actor.id }) + await Group.destroy({ id: gid }) + await Group.create({ id: gid, parent_gid: 0, name: 'audit-test' }) + await User.create({ + id: actor.id, + gid, + username: 'audit-test', + email: 'audit-test@example.com', + password: 'Wh@tA-Decent#P6ssw0rd', + first_name: 'Audit', + last_name: 'Tester', + }) + await Zone.create(zone) + await ZoneRecord.create(record) +}) + +after(async () => { + await Audit.destroyByUser(actor.id) + await ZoneRecord.destroy({ id: zrid }) + await Zone.destroy({ id: zid }) + await User.destroy({ id: actor.id }) + await Group.destroy({ id: gid }) + await Group.disconnect() +}) + +describe('audit log', () => { + it('records and lists zone changes', async () => { + await Audit.logZone(actor, 'added', zone) + const result = await Audit.listZones({ gids: [gid], search: 'audit.example.com.' }) + assert.equal(result.filtered, 1) + assert.equal(result.rows[0].action, 'added') + assert.equal(result.rows[0].zone, zone.zone) + assert.equal(result.rows[0].zid, zid) + }) + + it('records and lists zone-record changes with their type', async () => { + const id = await Audit.logZoneRecord(actor, 'deleted', record, zone) + const result = await Audit.listZoneRecords({ zid, search: 'www.audit' }) + assert.equal(result.filtered, 1) + assert.equal(result.rows[0].action, 'deleted') + assert.equal(result.rows[0].owner, record.owner) + assert.equal(result.rows[0].type, 'A') + + const exact = await Audit.listZoneRecords({ zid, id }) + assert.equal(exact.total, 1) + assert.equal(exact.rows[0].id, id) + assert.equal((await Audit.listZoneRecords({ zid, id: id + 1 })).total, 0) + }) + + it('lists the actor global log with stable pagination', async () => { + const result = await Audit.listGlobal({ gids: [gid], limit: 1, offset: 0 }) + assert.equal(result.total, 2) + assert.equal(result.filtered, 2) + assert.equal(result.rows.length, 1) + assert.equal(result.rows[0].uid, actor.id) + + assert.equal((await Audit.listGlobal({ gids: [gid], uid: actor.id })).total, 2) + assert.equal((await Audit.listGlobal({ gids: [gid], uid: actor.id + 1 })).total, 0) + }) +}) diff --git a/lib/audit/index.js b/lib/audit/index.js new file mode 100644 index 0000000..cc01d00 --- /dev/null +++ b/lib/audit/index.js @@ -0,0 +1,24 @@ +import { storeType } from '../config.js' + +const type = storeType() + +let RepoClass +switch (type) { + case 'json': + case 'toml': + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default + break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break + default: + throw new Error(`audit: no store implementation for type "${type}"`) +} + +export default new RepoClass() diff --git a/lib/audit/store/base.js b/lib/audit/store/base.js new file mode 100644 index 0000000..d2a5597 --- /dev/null +++ b/lib/audit/store/base.js @@ -0,0 +1,130 @@ +/** + * Audit domain class – pure contract and cross-cutting logic. + * + * Has zero knowledge of how audit entries are persisted. All audit repository + * classes must extend this class and implement the repo contract. + * + * Repo contract: + * insertZoneLog(detail) → logId + * insertZoneRecordLog(detail) → logId + * insertGlobalLog(entry) → void + * listGlobal(args) → { rows, total, filtered, limit, offset } + * listZones(args) → same shape + * listZoneRecords(args) → same shape + * destroyByUser(uid) → boolean + */ +const actionDescription = { + added: 'initial creation', + deleted: 'deleted', + modified: 'modified', + moved: 'moved', + recovered: 'recovered', +} + +class AuditBase { + async logZone(actor, action, zone, previous = {}) { + const timestamp = Math.floor(Date.now() / 1000) + const detail = compact({ + gid: zone.gid, + zid: zone.id, + uid: actor.id, + action, + timestamp, + zone: zone.zone, + mailaddr: zone.mailaddr, + description: zone.description, + refresh: zone.refresh, + retry: zone.retry, + expire: zone.expire, + ttl: zone.ttl, + minimum: zone.minimum, + serial: zone.serial, + }) + const logId = await this.insertZoneLog(detail) + await this.insertGlobalLog({ + uid: actor.id, + timestamp, + action, + object: 'zone', + objectId: zone.id, + logId, + title: zone.zone, + description: describe(action, 'zone', zone, previous), + }) + return logId + } + + async logZoneRecord(actor, action, record, zone, previous = {}) { + const timestamp = Math.floor(Date.now() / 1000) + const detail = compact({ + zid: record.zid, + zrid: record.id, + uid: actor.id, + action, + timestamp, + owner: record.owner, + ttl: record.ttl, + description: record.description, + type: record.type, + address: record.address, + weight: record.weight, + priority: record.priority, + other: record.other, + location: record.location, + }) + const logId = await this.insertZoneRecordLog(detail) + await this.insertGlobalLog({ + uid: actor.id, + timestamp, + action, + object: 'zone_record', + objectId: record.id, + logId, + title: record.owner, + description: describe(action, 'record', record, previous, zone), + }) + return logId + } + + async insertZoneLog(_detail) { + throw new Error('insertZoneLog() not implemented by this store') + } + + async insertZoneRecordLog(_detail) { + throw new Error('insertZoneRecordLog() not implemented by this store') + } + + async insertGlobalLog(_entry) { + throw new Error('insertGlobalLog() not implemented by this store') + } + + async listGlobal(_args) { + throw new Error('listGlobal() not implemented by this store') + } + + async listZones(_args) { + throw new Error('listZones() not implemented by this store') + } + + // args.ids, when present, limits the rows to those zone record ids + async listZoneRecords(_args) { + throw new Error('listZoneRecords() not implemented by this store') + } + + async destroyByUser(_uid) { + throw new Error('destroyByUser() not implemented by this store') + } +} + +function compact(obj) { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) +} + +function describe(action, object, current, previous, zone) { + if (action === 'moved') return `moved from group ${previous.gid} to ${current.gid}` + if (action === 'deleted' && object === 'record') return `deleted record from ${zone.zone}` + if (action === 'recovered' && object === 'record') return `recovered ${current.type} record` + return `${actionDescription[action] ?? action} ${object}` +} + +export default AuditBase diff --git a/lib/audit/store/elasticsearch.js b/lib/audit/store/elasticsearch.js new file mode 100644 index 0000000..7d1beea --- /dev/null +++ b/lib/audit/store/elasticsearch.js @@ -0,0 +1,29 @@ +import AuditBase from './base.js' + +class AuditRepoElasticsearch extends AuditBase { + async insertZoneLog(_detail) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async insertZoneRecordLog(_detail) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async insertGlobalLog(_entry) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async listGlobal(_args) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async listZones(_args) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } + + async listZoneRecords(_args) { + throw new Error('AuditRepoElasticsearch is not yet implemented') + } +} + +export default AuditRepoElasticsearch diff --git a/lib/audit/store/file.js b/lib/audit/store/file.js new file mode 100644 index 0000000..e731491 --- /dev/null +++ b/lib/audit/store/file.js @@ -0,0 +1,202 @@ +import FileStore from '../../store/file.js' +import { pageLimit } from '../../page.js' + +import AuditBase from './base.js' + +class AuditRepoFile extends AuditBase { + constructor() { + super() + this.zoneLog = new FileStore('zone_log') + this.recordLog = new FileStore('record_log') + this.globalLog = new FileStore('global_log') + } + + async insertZoneLog(detail) { + return this.zoneLog.update('zone_log', (rows) => { + const row = { id: nextId(rows), ...detail } + rows.push(row) + return row.id + }) + } + + async insertZoneRecordLog(detail) { + return this.recordLog.update('record_log', (rows) => { + const row = { id: nextId(rows), ...detail } + rows.push(row) + return row.id + }) + } + + async insertGlobalLog(entry) { + await this.globalLog.update('global_log', (rows) => + rows.push({ + id: nextId(rows), + uid: entry.uid, + timestamp: entry.timestamp, + action: entry.action, + object: entry.object, + object_id: entry.objectId, + log_entry_id: entry.logId, + title: entry.title, + description: entry.description, + }), + ) + } + + async listGlobal(args) { + const users = await loadUsers() + const groups = await loadGroups() + let rows = (await this.globalLog.load('global_log')).map((row) => { + const user = users.find((u) => u.id === row.uid) + return { + ...row, + gid: user?.gid, + group_name: groups.find((g) => g.id === user?.gid)?.name ?? '', + user: displayUser(user), + } + }) + + const gids = intValues(args.gids) + rows = rows.filter((row) => gids.includes(row.gid)) + if (Number.isInteger(args.uid)) rows = rows.filter((row) => row.uid === args.uid) + + return page(rows, args, { + searchKeys: ['user', 'action', 'object', 'title', 'description'], + sortMap: { + timestamp: 'timestamp', + user: 'user', + action: 'action', + object: 'object', + title: 'title', + description: 'description', + group_name: 'group_name', + }, + }) + } + + async listZones(args) { + const users = await loadUsers() + const groups = await loadGroups() + const rows = (await this.zoneLog.load('zone_log')).map((row) => ({ + ...row, + group_name: groups.find((g) => g.id === row.gid)?.name ?? '', + user: displayUser(users.find((u) => u.id === row.uid)), + })) + + const gids = intValues(args.gids) + const scoped = rows.filter((row) => gids.includes(row.gid)) + + return page(scoped, args, { + searchKeys: ['zone', 'description', 'action', 'user', 'group_name'], + sortMap: { + timestamp: 'timestamp', + user: 'user', + action: 'action', + zone: 'zone', + ttl: 'ttl', + description: 'description', + group_name: 'group_name', + }, + }) + } + + async listZoneRecords(args) { + const users = await loadUsers() + let rows = (await this.recordLog.load('record_log')).map((row) => ({ + ...row, + user: displayUser(users.find((u) => u.id === row.uid)), + })) + + rows = rows.filter((row) => row.zid === args.zid) + if (Number.isInteger(args.id)) rows = rows.filter((row) => row.id === args.id) + if (Array.isArray(args.ids)) rows = rows.filter((row) => args.ids.includes(row.zrid)) + + return page(rows, args, { + searchKeys: ['owner', 'description', 'type', 'address', 'action', 'user'], + sortMap: { + timestamp: 'timestamp', + user: 'user', + action: 'action', + owner: 'owner', + type: 'type', + address: 'address', + ttl: 'ttl', + weight: 'weight', + description: 'description', + }, + }) + } + + async destroyByUser(uid) { + const remove = (store, key) => + store.update(key, (rows) => { + let removed = false + for (let i = rows.length - 1; i >= 0; i -= 1) { + if (rows[i].uid === uid) { + rows.splice(i, 1) + removed = true + } + } + return removed + }) + const results = await Promise.all([ + remove(this.zoneLog, 'zone_log'), + remove(this.recordLog, 'record_log'), + remove(this.globalLog, 'global_log'), + ]) + return results.some(Boolean) + } +} + +async function loadUsers() { + return new FileStore('user').load('user') +} + +async function loadGroups() { + return new FileStore('group').load('group') +} + +function displayUser(user) { + if (!user) return '' + return `${user.first_name ?? ''} ${user.last_name ?? ''} (${user.username})`.trim() +} + +function intValues(values) { + return (Array.isArray(values) ? values : [values]).map(Number).filter(Number.isInteger) +} + +function nextId(rows) { + return rows.reduce((max, row) => Math.max(max, row.id ?? 0), 0) + 1 +} + +/** Search/sort/paginate in memory, mirroring the mysql listing behavior. */ +async function page(rows, args, { searchKeys, sortMap }) { + const limit = await pageLimit(args.limit, 50) + const offset = Number.isInteger(args.offset) ? args.offset : 0 + const total = rows.length + + const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' + if (search !== '') { + rows = rows.filter((row) => + searchKeys.some((key) => { + const value = String(row[key] ?? '').toLowerCase() + return args.exact_match === true ? value === search : value.includes(search) + }), + ) + } + + // LIKE treats % and _ as wildcards; here they match literally + const filteredCount = rows.length + + const sortBy = sortMap[args.sort_by] ?? sortMap.timestamp + const dir = args.sort_dir === 'asc' ? 1 : -1 + rows.sort((a, b) => { + if (a[sortBy] < b[sortBy]) return -dir + if (a[sortBy] > b[sortBy]) return dir + return (b.id ?? 0) - (a.id ?? 0) // stable pagination tiebreak, as in sql + }) + + return { rows: rows.slice(offset, offset + limit), total, filtered: filteredCount, limit, offset } +} + +export default AuditRepoFile diff --git a/lib/audit/store/mongodb.js b/lib/audit/store/mongodb.js new file mode 100644 index 0000000..b09a410 --- /dev/null +++ b/lib/audit/store/mongodb.js @@ -0,0 +1,29 @@ +import AuditBase from './base.js' + +class AuditRepoMongoDB extends AuditBase { + async insertZoneLog(_detail) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async insertZoneRecordLog(_detail) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async insertGlobalLog(_entry) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async listGlobal(_args) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async listZones(_args) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } + + async listZoneRecords(_args) { + throw new Error('AuditRepoMongoDB is not yet implemented') + } +} + +export default AuditRepoMongoDB diff --git a/lib/audit/store/mysql.js b/lib/audit/store/mysql.js new file mode 100644 index 0000000..7032b0b --- /dev/null +++ b/lib/audit/store/mysql.js @@ -0,0 +1,191 @@ +import * as RR from '@nictool/dns-resource-record' + +import Mysql from '../../mysql.js' +import { pageLimit } from '../../page.js' + +import AuditBase from './base.js' + +class AuditRepoMysql extends AuditBase { + async insertZoneLog(detail) { + return Mysql.execute(...Mysql.insert('nt_zone_log', mapZone(detail))) + } + + async insertZoneRecordLog(detail) { + return Mysql.execute(...Mysql.insert('nt_zone_record_log', mapRecord(detail))) + } + + async insertGlobalLog({ uid, timestamp, action, object, objectId, logId, title, description }) { + return Mysql.execute( + ...Mysql.insert('nt_user_global_log', { + nt_user_id: uid, + timestamp, + action, + object, + object_id: objectId, + log_entry_id: logId, + title, + description, + }), + ) + } + + async listGlobal(args) { + const scope = groupScope('u.nt_group_id', args.gids) + const where = Number.isInteger(args.uid) ? `${scope.sql} AND gl.nt_user_id = ?` : scope.sql + const params = Number.isInteger(args.uid) ? [...scope.params, args.uid] : scope.params + return listRows({ + select: `SELECT gl.nt_user_global_log_id AS id, + gl.nt_user_id AS uid, u.nt_group_id AS gid, g.name AS group_name, + gl.timestamp, gl.action, gl.object, gl.object_id, gl.target, + gl.target_id, gl.target_name, gl.log_entry_id, gl.title, gl.description, + CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, + from: `FROM nt_user_global_log gl + JOIN nt_user u ON u.nt_user_id = gl.nt_user_id + JOIN nt_group g ON g.nt_group_id = u.nt_group_id`, + where, + params, + searchColumns: ['u.username', 'gl.action', 'gl.object', 'gl.title', 'gl.description'], + sortMap: { + timestamp: 'gl.timestamp', + user: 'u.username', + action: 'gl.action', + object: 'gl.object', + title: 'gl.title', + description: 'gl.description', + group_name: 'g.name', + }, + args, + }) + } + + async listZones(args) { + const scope = groupScope('zl.nt_group_id', args.gids) + return listRows({ + select: `SELECT zl.nt_zone_log_id AS id, zl.nt_group_id AS gid, + zl.nt_user_id AS uid, zl.nt_zone_id AS zid, zl.timestamp, zl.action, + zl.zone, zl.mailaddr, zl.description, zl.serial, zl.refresh, zl.retry, + zl.expire, zl.minimum, zl.ttl, zl.location, g.name AS group_name, + CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, + from: `FROM nt_zone_log zl + JOIN nt_user u ON u.nt_user_id = zl.nt_user_id + JOIN nt_group g ON g.nt_group_id = zl.nt_group_id`, + where: scope.sql, + params: scope.params, + searchColumns: ['zl.zone', 'zl.description', 'zl.action', 'u.username', 'g.name'], + sortMap: { + timestamp: 'zl.timestamp', + user: 'u.username', + action: 'zl.action', + zone: 'zl.zone', + ttl: 'zl.ttl', + description: 'zl.description', + group_name: 'g.name', + }, + args, + }) + } + + async listZoneRecords(args) { + let where = Number.isInteger(args.id) + ? 'rl.nt_zone_id = ? AND rl.nt_zone_record_log_id = ?' + : 'rl.nt_zone_id = ?' + const params = Number.isInteger(args.id) ? [args.zid, args.id] : [args.zid] + if (Array.isArray(args.ids)) { + where += args.ids.length + ? ` AND rl.nt_zone_record_id IN (${args.ids.map(() => '?').join(', ')})` + : ' AND 0' + params.push(...args.ids) + } + return listRows({ + select: `SELECT rl.nt_zone_record_log_id AS id, rl.nt_zone_id AS zid, + rl.nt_user_id AS uid, rl.nt_zone_record_id AS zrid, rl.timestamp, + rl.action, rl.name AS owner, rl.ttl, rl.description, rt.name AS type, + rl.address, rl.weight, rl.priority, rl.other, rl.location, + CONCAT(u.first_name, ' ', u.last_name, ' (', u.username, ')') AS user`, + from: `FROM nt_zone_record_log rl + JOIN nt_user u ON u.nt_user_id = rl.nt_user_id + JOIN nt_group g ON g.nt_group_id = u.nt_group_id + JOIN resource_record_type rt ON rt.id = rl.type_id`, + where, + params, + searchColumns: ['rl.name', 'rl.description', 'rt.name', 'rl.address', 'rl.action', 'u.username'], + sortMap: { + timestamp: 'rl.timestamp', + user: 'u.username', + action: 'rl.action', + owner: 'rl.name', + type: 'rt.name', + address: 'rl.address', + ttl: 'rl.ttl', + weight: 'rl.weight', + description: 'rl.description', + }, + args, + }) + } + + async destroyByUser(uid) { + let removed = false + for (const table of ['nt_zone_log', 'nt_zone_record_log', 'nt_user_global_log']) { + const result = await Mysql.execute(...Mysql.delete(table, { nt_user_id: uid })) + removed ||= result.affectedRows > 0 + } + return removed + } +} + +function mapZone(detail) { + const { gid, zid, uid, ...rest } = detail + return { nt_group_id: gid, nt_zone_id: zid, nt_user_id: uid, ...rest } +} + +function mapRecord(detail) { + const { owner, ...rest } = detail + const detailOut = { name: owner, ...rest } + if (detailOut.type !== undefined) detailOut.type_id = RR.typeMap[detailOut.type] + delete detailOut.type + const { zid, zrid, uid, ...cols } = detailOut + return { nt_zone_id: zid, nt_zone_record_id: zrid, nt_user_id: uid, ...cols } +} + +async function listRows({ select, from, where, params, searchColumns, sortMap, args }) { + const limit = await pageLimit(args.limit, 50) + const offset = Number.isInteger(args.offset) ? args.offset : 0 + const total = await countRows(from, where, params) + + let filteredWhere = where + const filteredParams = [...params] + const search = typeof args.search === 'string' ? args.search.trim() : '' + if (search) { + const op = args.exact_match === true ? '= ?' : 'LIKE ?' + filteredWhere += ` AND (${searchColumns.map((column) => `${column} ${op}`).join(' OR ')})` + const value = args.exact_match === true ? search : `%${search}%` + filteredParams.push(...searchColumns.map(() => value)) + } + + const filtered = search ? await countRows(from, filteredWhere, filteredParams) : total + const sortBy = sortMap[args.sort_by] ?? sortMap.timestamp + const sortDir = args.sort_dir === 'asc' ? 'ASC' : 'DESC' + const rows = await Mysql.execute( + `${select} ${from} WHERE ${filteredWhere} + ORDER BY ${sortBy} ${sortDir}, id DESC LIMIT ${limit} OFFSET ${offset}`, + filteredParams, + ) + return { rows, total, filtered, limit, offset } +} + +async function countRows(from, where, params) { + const rows = await Mysql.execute(`SELECT COUNT(*) AS total ${from} WHERE ${where}`, params) + return rows[0].total +} + +function groupScope(column, gids) { + const values = (Array.isArray(gids) ? gids : [gids]).map(Number).filter(Number.isInteger) + if (values.length === 0) return { sql: '1 = 0', params: [] } + return { + sql: `${column} IN (${values.map(() => '?').join(', ')})`, + params: values, + } +} + +export default AuditRepoMysql diff --git a/lib/audit/test/index.js b/lib/audit/test/index.js new file mode 100644 index 0000000..0450095 --- /dev/null +++ b/lib/audit/test/index.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { describe, it, before, after } from 'node:test' + +import Audit from '../index.js' +import Group from '../../group/index.js' +import User from '../../user/index.js' +import Zone from '../../zone/index.js' +import ZoneRecord from '../../zone_record/index.js' + +import groupCase from '../../group/test/group.json' with { type: 'json' } +import userCase from '../../user/test/user.json' with { type: 'json' } + +const zone = { id: 4300, gid: groupCase.id, zone: 'audit.example.com.' } +const seen = { + id: 4301, + zid: zone.id, + owner: 'seen.audit.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const hidden = { + id: 4302, + zid: zone.id, + owner: 'hidden.audit.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} + +before(async () => { + await Group.create(groupCase) + await User.create(userCase) + await Zone.destroy({ id: zone.id }) + await Zone.create({ + ...zone, + mailaddr: 'hostmaster.audit.example.com.', + serial: 1, + refresh: 1, + retry: 1, + expire: 1, + minimum: 1, + ttl: 1, + }) + for (const record of [seen, hidden]) { + await ZoneRecord.destroy({ id: record.id }) + await ZoneRecord.create(record) + await Audit.logZoneRecord(userCase, 'added', record, zone) + } +}) + +after(async () => { + for (const record of [seen, hidden]) await ZoneRecord.destroy({ id: record.id }) + await Zone.destroy({ id: zone.id }) + await Zone.disconnect() +}) + +describe('audit', () => { + it('lists every record log row in a zone', async () => { + const { rows } = await Audit.listZoneRecords({ zid: zone.id, search: 'audit.example.com' }) + assert.deepEqual(rows.map((r) => r.zrid).sort(), [seen.id, hidden.id]) + }) + + it('limits record log rows to the ids a caller may read', async () => { + const { rows } = await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] }) + assert.deepEqual( + rows.map((r) => r.zrid), + [seen.id], + ) + }) + + it('keeps every row when writers overlap', async () => { + const before = (await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] })).rows + const ids = await Promise.all( + [1, 2, 3, 4, 5].map(() => Audit.logZoneRecord(userCase, 'modified', seen, zone)), + ) + assert.equal(new Set(ids).size, ids.length) + const after = (await Audit.listZoneRecords({ zid: zone.id, ids: [seen.id] })).rows + assert.equal(after.length, before.length + ids.length) + }) + + it('returns nothing for an empty read scope', async () => { + const { rows } = await Audit.listZoneRecords({ zid: zone.id, ids: [] }) + assert.deepEqual(rows, []) + }) +}) diff --git a/lib/authz-plugin.js b/lib/authz-plugin.js new file mode 100644 index 0000000..6b2d0b0 --- /dev/null +++ b/lib/authz-plugin.js @@ -0,0 +1,294 @@ +import Authz from './authz/index.js' +import Session from './session/index.js' +import Zone from './zone/index.js' +import ZoneRecord from './zone_record/index.js' + +const TYPE_TO_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', + NAMESERVER: 'nameserver', + GROUP: 'group', +} + +const DELEGABLE_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', +} + +const authzPlugin = { + name: 'nt-authz', + register(server) { + server.ext('onPreHandler', async (request, h) => { + const isLogin = request.method === 'post' && request.route.path === '/session' + if (request.auth.isAuthenticated && !isLogin) { + const credentials = await Authz.getCurrentCredentials(request.auth.credentials) + if (!credentials) { + return h + .response({ + error_code: 401, + error_msg: 'Session is no longer valid', + }) + .code(401) + .takeover() + } + request.auth.credentials = credentials + // idle expiry must measure activity; Session.put throttles to once a minute + Session.put({ id: credentials.session.id, last_access: true }).catch((err) => { + console.error(`session ${credentials.session.id} activity: ${err.message}`) + }) + } + + const permCfg = request.route.settings.app?.permission + if (!permCfg) return h.continue + + if (!request.auth.isAuthenticated) return h.continue + + let { resource, action } = permCfg + const credentials = request.auth.credentials + + if (resource === 'permission') { + const result = + action === 'create' + ? await Authz.checkPermissionTarget(credentials, request.payload) + : await Authz.checkPermissionRecord( + credentials, + action, + Number(resolveId(request, permCfg.idFrom)), + ) + return respond(result, h) + } + + if (action === 'readDelegation') { + const type = request.query?.type ?? 'ZONE' + const delegatedResource = TYPE_TO_RESOURCE[type] + if (!delegatedResource) { + return respond({ allowed: false, code: 404, msg: `Unknown delegation type` }, h) + } + if (request.query.oid !== undefined) { + const object = await Authz.checkPermission( + credentials, + delegatedResource, + 'read', + Number(request.query.oid), + ) + if (!object.allowed) return respond(object, h) + } + if (request.query.gid !== undefined) { + const group = await Authz.checkPermission(credentials, 'group', 'read', Number(request.query.gid)) + if (!group.allowed) return respond(group, h) + } + if (request.query.oid === undefined && request.query.gid === undefined) { + return respond({ allowed: false, code: 404, msg: `A delegation scope is required` }, h) + } + return h.continue + } + + let objectId + if (permCfg.idFrom) { + objectId = resolveId(request, permCfg.idFrom) + if (objectId !== undefined) objectId = Number(objectId) + } + + if (permCfg.targetGroupFrom) { + const targetGid = resolveId(request, permCfg.targetGroupFrom) + // a PUT that keeps the current group is an edit, not a move + const currentGid = targetGid === undefined ? null : await Authz.getObjectGroupId(resource, objectId) + if (targetGid !== undefined && Number(targetGid) !== currentGid) { + if ( + resource === 'user' && + action === 'write' && + objectId === credentials.user.id && + Number(targetGid) !== credentials.group.id + ) { + return respond( + { + allowed: false, + code: 403, + msg: `Cannot move yourself to another group`, + }, + h, + ) + } + if ( + resource === 'group' && + action === 'write' && + (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) + + // moves stay inside the owning tree, as in v2 + if (currentGid !== null && !(await Authz.isInGroupTree(credentials.group.id, currentGid))) { + return respond({ allowed: false, code: 404, msg: `Cannot move a delegated ${resource}` }, h) + } + } + } + + const sourceZid = await movedFromZone(request, resource, action, objectId) + if (sourceZid !== null) { + // moving out of a zone needs delete rights where the record lives, + // not just create rights where it's going + const source = await Authz.checkPermission(credentials, resource, 'delete', objectId) + if (!source.allowed) return respond(source, h) + + const target = await resolveTargetGroup(request, permCfg.targetCreateResource) + const targetResult = await Authz.checkPermission( + credentials, + permCfg.targetCreateResource, + 'create', + undefined, + { targetGroupId: target?.gid, targetZoneId: target?.zid }, + ) + if (!targetResult.allowed) return respond(targetResult, h) + } + + if (action === 'read' && objectId === undefined) { + const list = permCfg.list + if (!list) return h.continue + resource = list.resource + objectId = resolveId(request, list.idFrom) + if (objectId === undefined && list.defaultToGroup) { + objectId = credentials.group.id + } + if (objectId === undefined) { + return respond( + { + allowed: false, + code: 404, + msg: `A scoped collection id is required`, + }, + h, + ) + } + objectId = Number(objectId) + } + + if (action.endsWith('Delegation') || action === 'delegate') { + const type = request.payload?.type ?? request.query?.type + // only zones and zone records are delegable; nothing caps the granted + // permissions for the other nt_delegate types, so refuse them outright + if (!DELEGABLE_RESOURCE[type]) { + return respond( + { + allowed: false, + code: 404, + msg: `${type} objects cannot be delegated`, + }, + h, + ) + } + resource = DELEGABLE_RESOURCE[type] + + const targetGid = request.payload?.gid ?? request.query?.gid + if (targetGid !== undefined) { + if (!(await Authz.isActiveGroup(Number(targetGid)))) { + return respond({ allowed: false, code: 404, msg: `Delegation target group is deleted` }, h) + } + const target = await Authz.checkPermission(credentials, 'group', 'read', Number(targetGid)) + if (!target.allowed) return respond(target, h) + if (request.method === 'post' && Number(targetGid) === credentials.group.id) { + return respond( + { + allowed: false, + code: 404, + msg: `Cannot delegate to your own group`, + }, + h, + ) + } + } + } + + let opts + if (action === 'create') { + if (request.payload?.id !== undefined) { + const existingGid = await Authz.getObjectGroupId(resource, request.payload.id) + if (existingGid !== null) { + return respond({ allowed: false, code: 404, msg: `That ${resource} id already exists` }, h) + } + } + const target = await resolveTargetGroup(request, resource) + opts = { + targetGroupId: target?.gid, + targetZoneId: target?.zid, + } + } + + const result = await Authz.checkPermission(credentials, resource, action, objectId, opts) + if (!result.allowed) return respond(result, h) + + // Only a PUT that actually flips the deleted flag is a delete; a client + // that echoes the object back unchanged needs no delete permission. + if (action === 'write' && request.payload?.deleted !== undefined) { + const wasDeleted = !(await Authz.isActiveObject(resource, objectId)) + if (Boolean(request.payload.deleted) !== wasDeleted) { + const deleteResult = await Authz.checkPermission(credentials, resource, 'delete', objectId) + return respond(deleteResult, h) + } + } + + return respond(result, h) + }) + }, +} + +function respond(result, h) { + if (result.allowed) return h.continue + return h + .response({ + error_code: result.code, + error_msg: result.msg, + }) + .code(403) + .takeover() +} + +function resolveId(request, idFrom) { + const [source, key] = idFrom.split('.') + if (source === 'params') return request.params[key] + if (source === 'payload') return request.payload?.[key] + if (source === 'query') return request.query?.[key] +} + +// The group a create lands in must be read from the same payload key the store +// persists, or authz and the store can be pointed at different groups. +const CREATE_GROUP_KEY = { + group: 'parent_gid', + nameserver: 'gid', + user: 'gid', + zone: 'gid', +} + +async function resolveTargetGroup(request, resource) { + if (resource === 'zonerecord') { + const zid = request.payload?.zid ?? request.payload?.nt_zone_id + if (zid) { + const zones = await Zone.get({ id: Number(zid) }) + const zone = zones.find((z) => z.deleted !== true) + if (zone) return { gid: zone.gid, zid: Number(zid) } + } + return null + } + + 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 movedFromZone(request, resource, action, objectId) { + if (resource !== 'zonerecord' || action !== 'write') return null + if (objectId === undefined || request.payload?.zid === undefined) return null + const records = await ZoneRecord.get({ id: objectId }) + if (records.length === 0) return null + return records[0].zid !== Number(request.payload.zid) ? records[0].zid : null +} + +export default authzPlugin diff --git a/lib/authz.test.js b/lib/authz.test.js new file mode 100644 index 0000000..7e9469f --- /dev/null +++ b/lib/authz.test.js @@ -0,0 +1,569 @@ +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/index.js' +import Authz from './authz/index.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 }) + + 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 }) + } + await Group.disconnect() +}) + +describe('checkPermission', () => { + describe('create actions', () => { + it('allows create when user has permission', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'create', undefined, { targetGroupId: 4200 }) + assert.equal(r.allowed, true) + }) + + it('allows create into child group', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'create', undefined, { targetGroupId: 4201 }) + assert.equal(r.allowed, true) + }) + + it('denies create when user lacks permission', async () => { + const r = await Authz.checkPermission(credsLimited, 'zone', 'create', undefined) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to create/) + }) + + it('denies create when target group not in tree', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'create', undefined, { targetGroupId: 4202 }) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + }) + + describe('self-user restrictions', () => { + it('denies delete self', async () => { + const r = await Authz.checkPermission(credsFull, 'user', 'delete', 4200) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to delete self/) + }) + + it('allows write self when self_write=true', async () => { + const r = await Authz.checkPermission(credsFull, 'user', 'write', 4200) + assert.equal(r.allowed, true) + }) + + it('denies write self when self_write=false', async () => { + const r = await Authz.checkPermission(credsNoself, 'user', 'write', 4202) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to modify self/) + }) + + it('allows read self', async () => { + const r = await Authz.checkPermission(credsFull, 'user', 'read', 4200) + assert.equal(r.allowed, true) + }) + }) + + describe('own-group restrictions', () => { + it('denies write to own group', async () => { + const r = await Authz.checkPermission(credsFull, 'group', 'write', 4200) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to edit your own group/) + }) + + it('denies delete own group', async () => { + const r = await Authz.checkPermission(credsFull, 'group', 'delete', 4200) + assert.equal(r.allowed, false) + assert.match(r.msg, /Not allowed to delete your own group/) + }) + }) + + describe('nameserver reads', () => { + it('allows authenticated read of an active nameserver', async () => { + const r = await Authz.checkPermission(credsLimited, 'nameserver', 'read', 4200) + assert.equal(r.allowed, true) + }) + }) + + describe('group tree ownership', () => { + it('allows read of in-tree zone', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'read', 4200) + assert.equal(r.allowed, true) + }) + + it('allows write of in-tree zone with permission', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'write', 4200) + assert.equal(r.allowed, true) + }) + + it('denies write when user lacks action permission', async () => { + const r = await Authz.checkPermission(credsLimited, 'zone', 'write', 4201) + assert.equal(r.allowed, false) + }) + }) + + describe('delegation access', () => { + it('allows read of delegated zone', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'read', 4201) + assert.equal(r.allowed, true) + }) + + it('allows write of delegated zone when perm_write=1', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'write', 4201) + assert.equal(r.allowed, true) + }) + + it('denies delete of delegated zone when perm_delete=0', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'delete', 4201) + assert.equal(r.allowed, false) + assert.match(r.msg, /no 'delete' permission/) + }) + + it('perm_delete never permits deleting the delegated object', async () => { + await Delegation.put({ + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_delete: true, + }) + const r = await Authz.checkPermission(credsFull, 'zone', 'delete', 4201) + assert.equal(r.allowed, false) + await Delegation.put({ + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_delete: false, + }) + }) + + it('allows delegate action when perm_delegate=1', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'delegate', 4201) + assert.equal(r.allowed, true) + }) + }) + + describe('pseudo-delegation (zone record via parent zone)', () => { + it('allows read of zone record in delegated zone', async () => { + const r = await Authz.checkPermission(credsFull, 'zonerecord', 'read', 4201) + assert.equal(r.allowed, true) + }) + }) + + describe('direct zone record delegation', () => { + it('allows read of directly delegated zone record', async () => { + const r = await Authz.checkPermission(credsFull, 'zonerecord', 'read', 4202) + assert.equal(r.allowed, true) + }) + + it('denies delete when perm_delete=0', async () => { + const r = await Authz.checkPermission(credsFull, 'zonerecord', 'delete', 4202) + assert.equal(r.allowed, false) + assert.match(r.msg, /no 'delete' permission/) + }) + }) + + describe('deny fallthrough', () => { + it('denies access to object not in tree and not delegated', async () => { + const r = await Authz.checkPermission(credsLimited, 'zone', 'read', 4200) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + + it('denies when object does not exist', async () => { + const r = await Authz.checkPermission(credsFull, 'zone', 'read', 99999) + assert.equal(r.allowed, false) + assert.match(r.msg, /No Access Allowed/) + }) + }) +}) + +describe('getObjectGroupId', () => { + it('returns group id for zone', async () => { + assert.equal(await Authz.getObjectGroupId('zone', 4200), 4200) + }) + + it('returns group id for zonerecord via join', async () => { + assert.equal(await Authz.getObjectGroupId('zonerecord', 4200), 4200) + }) + + it('returns group id for user', async () => { + assert.equal(await Authz.getObjectGroupId('user', 4200), 4200) + }) + + it('returns group id for nameserver', async () => { + assert.equal(await Authz.getObjectGroupId('nameserver', 4200), 4200) + }) + + it('returns parent_group_id for group', async () => { + assert.equal(await Authz.getObjectGroupId('group', 4201), 4200) + }) + + it('returns 1 for root group', async () => { + assert.equal(await Authz.getObjectGroupId('group', 4200), 1) + }) + + it('returns null for unknown resource type', async () => { + assert.equal(await Authz.getObjectGroupId('bogus', 4200), null) + }) + + it('returns null for nonexistent object', async () => { + assert.equal(await Authz.getObjectGroupId('zone', 99999), null) + }) +}) + +describe('isInGroupTree', () => { + it('returns true for same group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4200), true) + }) + + it('returns true for child group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4201), true) + }) + + it('returns false for unrelated group', async () => { + assert.equal(await Authz.isInGroupTree(4200, 4202), false) + }) + + it('returns false for parent from child perspective', async () => { + assert.equal(await Authz.isInGroupTree(4201, 4200), false) + }) +}) + +describe('getDelegateAccess', () => { + it('returns delegation row for directly delegated zone', async () => { + const d = await Authz.getDelegateAccess(4200, 4201, 'zone') + assert.ok(d) + assert.equal(d.perm_write, 1) + assert.equal(d.perm_delete, 0) + }) + + it('returns null for non-delegated zone', async () => { + const d = await Authz.getDelegateAccess(4202, 4200, 'zone') + assert.equal(d, null) + }) + + it('returns pseudo-delegation for zone record via parent zone', async () => { + const d = await Authz.getDelegateAccess(4200, 4201, 'zonerecord') + assert.ok(d) + assert.equal(d.pseudo, 1) + }) + + it('returns direct delegation for zone record', async () => { + const d = await Authz.getDelegateAccess(4200, 4202, 'zonerecord') + assert.ok(d) + assert.equal(d.perm_write, 1) + assert.equal(d.perm_delete, 0) + }) + + it('returns null for unknown resource type', async () => { + const d = await Authz.getDelegateAccess(4200, 4200, 'bogus') + assert.equal(d, null) + }) +}) + +describe('capPermissions', () => { + it('removes fields user lacks permission for', () => { + const userPerm = { + zone: { create: true, write: false, delete: true }, + user: { create: false }, + } + const target = { + zone_create: 1, + zone_write: 1, + zone_delete: 1, + user_create: 1, + } + const capped = Authz.capPermissions(userPerm, target) + assert.equal(capped.zone_create, 1) + assert.equal(capped.zone_write, undefined) + assert.equal(capped.zone_delete, 1) + assert.equal(capped.user_create, undefined) + }) + + it('returns null/undefined inputs as-is', () => { + assert.equal(Authz.capPermissions({}, null), null) + assert.equal(Authz.capPermissions({}, undefined), undefined) + }) + + it('preserves usable nameservers the caller cannot manage', () => { + const capped = Authz.capPermissions( + { nameserver: { usable: ['1'] } }, + { usable_ns: [1] }, + { nameserver: { usable: ['1', '2'] } }, + ) + assert.deepEqual(capped.usable_ns, ['1', '2']) + }) + + it('rejects an inherited-permission transition the caller cannot grant', () => { + const userPerm = { zone: { delete: false }, user: { write: true } } + const before = { zone: { delete: false } } + const after = { zone: { delete: true } } + assert.equal(Authz.canTransitionPermissions(userPerm, before, after), false) + assert.equal(Authz.canTransitionPermissions(userPerm, after, after), true) + }) + + it('carries unmanaged permissions into a new explicit row', () => { + const preserved = Authz.preserveUnmanagedPermissions( + { zone: { delete: false }, user: { write: true } }, + { name: 'explicit' }, + { zone: { delete: true } }, + ) + assert.equal(preserved.zone_delete, true) + }) +}) diff --git a/lib/authz/index.js b/lib/authz/index.js new file mode 100644 index 0000000..2b79318 --- /dev/null +++ b/lib/authz/index.js @@ -0,0 +1,24 @@ +import { storeType } from '../config.js' + +const type = storeType() + +let RepoClass +switch (type) { + case 'json': + case 'toml': + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default + break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break + default: + throw new Error(`authz: no store implementation for type "${type}"`) +} + +export default new RepoClass() diff --git a/lib/authz/store/base.js b/lib/authz/store/base.js new file mode 100644 index 0000000..028fe6d --- /dev/null +++ b/lib/authz/store/base.js @@ -0,0 +1,358 @@ +/** + * Authz domain class – pure policy and cross-cutting logic. + * + * Has zero knowledge of how users, groups, delegations, or sessions are + * persisted. All authz repository classes must extend this class and implement + * the repo contract. + * + * Repo contract: + * objectGroupId(resource, objectId) → gid | null + * isInGroupTree(userGid, targetGid) → boolean + * isActiveGroup(gid) → boolean + * isActiveObject(resource, objectId) → boolean + * getDirectDelegateAccess(gid, oid, resource) → row | null + * getDelegatedZoneIds(groupIds) → number[] + * delegatedRecordIdsInZone(gid, zid) → number[] ([] when no access) + * zonePseudoDelegation(gid, zid) → row | null + * zoneDelegationForRecord(gid, zrid) → row | null + * liveSessionGroup(userId, sessionId, oldestSec) → gid | null + * permissionRecord(permissionId) → { uid, gid, target_gid } | null + */ +import Permission from '../../permission/index.js' + +const DELEGATE_TYPE = { + zone: 'ZONE', + zonerecord: 'ZONERECORD', + nameserver: 'NAMESERVER', + group: 'GROUP', +} + +const PERM_FIELDS = [ + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', +] + +const CREATE_REQUIRES_GROUP = new Set(['group', 'nameserver', 'user', 'zone', 'zonerecord']) + +const ACTION_PERMISSION = { + editDelegation: 'delegate', + deleteDelegation: 'delegate', +} + +const SESSION_MAX_AGE_SEC = 14400 + +class AuthzBase { + async checkPermission(credentials, resource, action, objectId, opts) { + const perm = await Permission.getEffective(credentials.user.id) + if (!perm) return deny(`No permissions found`) + + if (action === 'create') { + if (perm[resource]?.create !== true) { + return deny(`Not allowed to create new ${resource}`) + } + const targetGid = opts?.targetGroupId + if (targetGid === undefined || targetGid === null) { + if (CREATE_REQUIRES_GROUP.has(resource)) { + return deny(`No target group found for new ${resource}`) + } + } else { + if (!(await this.isActiveGroup(targetGid))) { + return deny(`No active target group found for new ${resource}`) + } + const inTree = await this.isInGroupTree(credentials.group.id, targetGid) + if (!inTree) { + if (resource === 'zonerecord' && opts?.targetZoneId) { + const delegation = await this.getDelegateAccess(credentials.group.id, opts.targetZoneId, 'zone') + if (delegation?.zone_perm_add_records === 1) return allow() + if (delegation) { + return deny(`Not allowed to add records to the delegated zone.`) + } + } + return deny( + `No Access Allowed to that object` + ` (${DELEGATE_TYPE[resource] ?? 'GROUP'} : ${targetGid})`, + ) + } + } + return allow() + } + + if (resource === 'user' && objectId === credentials.user.id) { + if (action === 'delete') return deny(`Not allowed to delete self`) + if (action === 'write') { + if (perm.self_write !== true) return deny(`Not allowed to modify self`) + return allow() + } + return allow() + } + + if (resource === 'group' && objectId === credentials.group.id) { + if (action === 'write') return deny(`Not allowed to edit your own group`) + if (action === 'delete') return deny(`Not allowed to delete your own group`) + if (action === 'read') return allow() + } + + if (resource === 'nameserver' && action === 'read' && (await this.isActiveObject(resource, objectId))) { + return allow() + } + + if ( + ['delegate', 'editDelegation', 'deleteDelegation'].includes(action) && + !(await this.isActiveObject(resource, objectId)) + ) { + return deny(`Cannot change delegation for a deleted object`) + } + + const objGroupId = await this.getObjectGroupId(resource, objectId) + if (objGroupId === null) { + return deny(`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`) + } + + if (await this.isInGroupTree(credentials.group.id, objGroupId)) { + if (action === 'read') return allow() + const permissionAction = ACTION_PERMISSION[action] ?? action + if (perm[resource]?.[permissionAction] === true) return allow() + return deny(`You have no '${action}' permission for ${resource} objects`) + } + + const delegation = await this.getDelegateAccess(credentials.group.id, objectId, resource) + if (delegation) { + if (action === 'read') return allow({ delegation }) + const displayAction = ACTION_PERMISSION[action] ?? action + if (action === 'editDelegation') { + return deny(`You have no '${displayAction}' permission for the delegated object`) + } + // v2 sets pseudo => 'none' on every delegate action: access inherited + // from a parent object never carries the right to delegate + if (delegation.pseudo && action === 'delegate') { + return deny(`You have no '${action}' permission for the delegated object`) + } + if (resource === 'zonerecord' && delegation.pseudo && action === 'delete') { + if (delegation.zone_perm_delete_records === 1) return allow() + return deny(`You have no '${action}' permission for the delegated object`) + } + if (action === 'delete') { + return deny(`You have no '${action}' permission for the delegated object`) + } + const permField = action === 'deleteDelegation' ? 'perm_delete' : `perm_${action}` + if (delegation[permField] === 1) return allow() + return deny(`You have no '${displayAction}' permission for the delegated object`) + } + + return deny(`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`) + } + + async getDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + + const direct = await this.getDirectDelegateAccess(groupId, objectId, resource) + if (direct) return direct + + if (resource === 'zonerecord') { + return this.zoneDelegationForRecord(groupId, objectId) + } + if (resource === 'zone') { + return this.zonePseudoDelegation(groupId, objectId) + } + return null + } + + 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 + return this.delegatedRecordIdsInZone(groupId, zoneId) + } + + // v2 grants read on a zone to any group holding a delegation on one of its + // records. Every permission is 0, so only the read fast-paths above accept it. + async zonePseudoDelegation(groupId, zoneId) { + const rows = await this.delegatedRecordIdsInZone(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, + } + } + + capPermissions(userPerm, targetPerms, existingPerm) { + if (!targetPerms || !userPerm) return targetPerms + + const capped = { ...targetPerms } + for (const field of PERM_FIELDS) { + if (capped[field] === undefined) continue + const [resource] = field.split('_', 2) + const remaining = field.slice(resource.length + 1) + if (userPerm[resource]?.[remaining] !== true) { + delete capped[field] + } + } + + for (const resource of ['group', 'nameserver', 'user', 'zone', 'zonerecord']) { + if (!capped[resource]) continue + capped[resource] = { ...capped[resource] } + for (const action of ['create', 'write', 'delete', 'delegate']) { + if (capped[resource][action] === undefined) continue + if (userPerm[resource]?.[action] !== true) delete capped[resource][action] + } + } + + if (capped.self_write !== undefined && userPerm.user?.write !== true) { + delete capped.self_write + } + + const usable = userPerm.nameserver?.usable ?? [] + const existingUsable = existingPerm?.nameserver?.usable ?? [] + if (Array.isArray(capped.usable_ns)) { + capped.usable_ns = capUsableNameservers(capped.usable_ns, usable, existingUsable) + } + if (Array.isArray(capped.nameserver?.usable)) { + capped.nameserver.usable = capUsableNameservers(capped.nameserver.usable, usable, existingUsable) + } + return capped + } + + canTransitionPermissions(userPerm, before, after) { + for (const field of PERM_FIELDS) { + const [resource] = field.split('_', 1) + const action = field.slice(resource.length + 1) + if (userPerm[resource]?.[action] === true) continue + if (Boolean(before?.[resource]?.[action]) !== Boolean(after?.[resource]?.[action])) { + return false + } + } + + if (userPerm.user?.write !== true && Boolean(before?.self_write) !== Boolean(after?.self_write)) { + return false + } + + const allowed = new Set((userPerm.nameserver?.usable ?? []).map(String)) + const oldUsable = new Set((before?.nameserver?.usable ?? []).map(String)) + const newUsable = new Set((after?.nameserver?.usable ?? []).map(String)) + for (const id of new Set([...oldUsable, ...newUsable])) { + if (oldUsable.has(id) !== newUsable.has(id) && !allowed.has(id)) return false + } + return true + } + + preserveUnmanagedPermissions(userPerm, targetPerms, currentPerm) { + const preserved = { ...targetPerms } + for (const field of PERM_FIELDS) { + const [resource] = field.split('_', 1) + const action = field.slice(resource.length + 1) + if (userPerm[resource]?.[action] !== true) { + preserved[field] = Boolean(currentPerm?.[resource]?.[action]) + } + } + if (userPerm.user?.write !== true) { + preserved.self_write = Boolean(currentPerm?.self_write) + } + return preserved + } + + async getCurrentCredentials(credentials) { + // Match the JWT's maximum token age when checking server-side revocation. + const oldest = Math.floor(Date.now() / 1000) - SESSION_MAX_AGE_SEC + const gid = await this.liveSessionGroup(credentials.user.id, credentials.session.id, oldest) + if (gid === null) return null + return { + ...credentials, + group: { ...credentials.group, id: gid }, + } + } + + async checkPermissionRecord(credentials, action, permissionId) { + const record = await this.permissionRecord(permissionId) + if (!record || record.target_gid === null) { + return deny(`No Access Allowed to that permission (${permissionId})`) + } + + if (action === 'read') { + return (await this.isInGroupTree(credentials.group.id, record.target_gid)) + ? allow() + : deny(`No Access Allowed to that permission (${permissionId})`) + } + + if (record.uid !== null) { + if (record.uid === credentials.user.id) { + return deny(`Not allowed to modify your own permissions`) + } + if (!(await this.isActiveObject('user', record.uid))) { + return deny(`Cannot modify permissions for a deleted user`) + } + return this.checkPermission(credentials, 'user', 'write', record.uid) + } + if (!(await this.isActiveGroup(record.gid))) { + return deny(`Cannot modify permissions for a deleted group`) + } + return this.checkPermission(credentials, 'group', 'write', record.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(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 AuthzBase diff --git a/lib/authz/store/elasticsearch.js b/lib/authz/store/elasticsearch.js new file mode 100644 index 0000000..13c0fdf --- /dev/null +++ b/lib/authz/store/elasticsearch.js @@ -0,0 +1,45 @@ +import AuthzBase from './base.js' + +class AuthzRepoElasticsearch extends AuthzBase { + async getObjectGroupId(_resource, _objectId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async isInGroupTree(_userGroupId, _targetGroupId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async isActiveGroup(_groupId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async isActiveObject(_resource, _objectId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async getDirectDelegateAccess(_groupId, _objectId, _resource) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async getDelegatedZoneIds(_groupIds) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async delegatedRecordIdsInZone(_groupId, _zoneId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async zoneDelegationForRecord(_groupId, _zoneRecordId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async liveSessionGroup(_userId, _sessionId, _oldestSec) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } + + async permissionRecord(_permissionId) { + throw new Error('AuthzRepoElasticsearch is not yet implemented') + } +} + +export default AuthzRepoElasticsearch diff --git a/lib/authz/store/file.js b/lib/authz/store/file.js new file mode 100644 index 0000000..652045e --- /dev/null +++ b/lib/authz/store/file.js @@ -0,0 +1,177 @@ +import Delegation from '../../delegation/index.js' +import FileStore from '../../store/file.js' + +import AuthzBase from './base.js' + +// object lookups by authz resource: which file holds the rows and which key +// carries the group +const RESOURCES = { + zone: { file: 'zone' }, + zonerecord: { file: 'zone_record' }, + user: { file: 'user' }, + nameserver: { file: 'nameserver' }, + group: { file: 'group', selfOwned: true }, +} + +class AuthzRepoFile extends AuthzBase { + async _rows(file) { + return new FileStore(file).load(file) + } + + async _object(resource, objectId) { + const meta = RESOURCES[resource] + if (!meta) return null + const rows = await this._rows(meta.file) + return rows.find((r) => r.id === objectId) ?? null + } + + async getObjectGroupId(resource, objectId) { + if (resource === 'zonerecord') { + const record = await this._object('zonerecord', objectId) + if (!record) return null + return this.getObjectGroupId('zone', record.zid) + } + const row = await this._object(resource, objectId) + if (!row) return null + if (resource === 'group') { + // the root group has no parent; v2 routes its objects to group 1 + if (row.parent_gid === 0) return 1 + return row.parent_gid ?? row.gid ?? 1 + } + return row.gid ?? null + } + + async isInGroupTree(userGroupId, targetGroupId) { + if (userGroupId === targetGroupId) return true + const groups = await this._rows('group') + const queue = [userGroupId] + const seen = new Set(queue) + while (queue.length > 0) { + const current = queue.shift() + for (const g of groups) { + if (g.parent_gid === current && !seen.has(g.id)) { + if (g.id === targetGroupId) return true + seen.add(g.id) + queue.push(g.id) + } + } + } + return false + } + + async isActiveGroup(groupId) { + const group = await this._object('group', groupId) + return group != null && group.deleted !== true + } + + async isActiveObject(resource, objectId) { + const row = await this._object(resource, objectId) + return row != null && row.deleted !== true + } + + async getDirectDelegateAccess(groupId, objectId, resource) { + const type = { zone: 'ZONE', zonerecord: 'ZONERECORD', nameserver: 'NAMESERVER', group: 'GROUP' }[ + resource + ] + if (!type) return null + + const delegations = await Delegation.getDelegates(objectId, type, groupId) + if (delegations.length === 0) return null + if (!(await this.isActiveObject(resource, objectId))) return null + const row = delegations[0] + // mysql returns raw nt_delegate columns here; shape them to match + return { + nt_group_id: row.nt_group_id, + nt_object_id: row.nt_object_id, + nt_object_type: row.nt_object_type, + perm_write: row.delegate_write, + perm_delete: row.delegate_delete, + perm_delegate: row.delegate_delegate, + zone_perm_add_records: row.delegate_add_records, + zone_perm_delete_records: row.delegate_delete_records, + } + } + + async getDelegatedZoneIds(groupIds) { + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]).map(Number).filter(Number.isInteger) + + const zones = await this._rows('zone') + const records = await this._rows('zone_record') + const zoneById = new Map(zones.map((z) => [z.id, z])) + + const ids = [] + for (const gid of gids) { + for (const d of await Delegation.getDelegated(gid, 'ZONE')) { + const zone = zoneById.get(d.nt_object_id) + if (zone?.deleted !== true) ids.push(d.nt_object_id) + } + for (const d of await Delegation.getDelegated(gid, 'ZONERECORD')) { + const record = records.find((r) => r.id === d.nt_object_id) + const zone = zoneById.get(record?.zid) + if (record?.deleted !== true && zone?.deleted !== true) ids.push(record.zid) + } + } + return [...new Set(ids)] + } + + async delegatedRecordIdsInZone(groupId, zoneId) { + const delegations = await Delegation.getDelegated(groupId, 'ZONERECORD') + const records = await this._rows('zone_record') + const zones = await this._rows('zone') + + const zone = zones.find((z) => z.id === zoneId) + if (!zone || zone.deleted === true) return [] + + return delegations + .map((d) => records.find((r) => r.id === d.nt_object_id)) + .filter((r) => r && r.deleted !== true && r.zid === zoneId) + .map((r) => r.id) + } + + async zoneDelegationForRecord(groupId, zoneRecordId) { + const records = await this._rows('zone_record') + const record = records.find((r) => r.id === zoneRecordId && r.deleted !== true) + if (!record) return null + + const direct = await this.getDirectDelegateAccess(groupId, record.zid, 'zone') + return direct ? { ...direct, pseudo: 1 } : null + } + + async liveSessionGroup(userId, sessionId, oldestSec) { + const users = await this._rows('user') + const groups = await this._rows('group') + const sessions = await this._rows('session') + + const user = users.find((u) => u.id === userId && u.deleted !== true) + if (!user) return null + const group = groups.find((g) => g.id === user.gid && g.deleted !== true) + if (!group) return null + + const session = sessions.find( + (s) => s.uid === userId && s.id === sessionId && (s.last_access ?? Infinity) >= oldestSec, + ) + return session ? user.gid : null + } + + async permissionRecord(permissionId) { + // permissions ride on user and group rows in the file store + const users = await this._rows('user') + const groups = await this._rows('group') + + for (const u of users) { + const p = u.permissions + if (p && (p.permissionId === permissionId || p.id === permissionId)) { + return { uid: u.id, gid: p.group?.id ?? u.gid, target_gid: u.gid } + } + } + for (const g of groups) { + const p = g.permissions + if (p && (p.permissionId === permissionId || p.id === permissionId)) { + return { uid: null, gid: g.id, target_gid: g.id } + } + } + return null + } +} + +export default AuthzRepoFile diff --git a/lib/authz/store/mongodb.js b/lib/authz/store/mongodb.js new file mode 100644 index 0000000..40a5065 --- /dev/null +++ b/lib/authz/store/mongodb.js @@ -0,0 +1,45 @@ +import AuthzBase from './base.js' + +class AuthzRepoMongoDB extends AuthzBase { + async getObjectGroupId(_resource, _objectId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async isInGroupTree(_userGroupId, _targetGroupId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async isActiveGroup(_groupId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async isActiveObject(_resource, _objectId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async getDirectDelegateAccess(_groupId, _objectId, _resource) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async getDelegatedZoneIds(_groupIds) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async delegatedRecordIdsInZone(_groupId, _zoneId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async zoneDelegationForRecord(_groupId, _zoneRecordId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async liveSessionGroup(_userId, _sessionId, _oldestSec) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } + + async permissionRecord(_permissionId) { + throw new Error('AuthzRepoMongoDB is not yet implemented') + } +} + +export default AuthzRepoMongoDB diff --git a/lib/authz/store/mysql.js b/lib/authz/store/mysql.js new file mode 100644 index 0000000..117ff5c --- /dev/null +++ b/lib/authz/store/mysql.js @@ -0,0 +1,175 @@ +import Mysql from '../../mysql.js' + +import AuthzBase from './base.js' + +const RESOURCE_QUERIES = { + zone: 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?', + zonerecord: `SELECT z.nt_group_id FROM nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE r.nt_zone_record_id = ?`, + user: 'SELECT nt_group_id FROM nt_user WHERE nt_user_id = ?', + group: 'SELECT parent_group_id AS nt_group_id FROM nt_group WHERE nt_group_id = ?', + nameserver: 'SELECT nt_group_id FROM nt_nameserver WHERE nt_nameserver_id = ?', +} + +const DELEGATE_TYPE = { + zone: 'ZONE', + zonerecord: 'ZONERECORD', + nameserver: 'NAMESERVER', + group: 'GROUP', +} + +function delegateTable(resource) { + return { + zone: 'nt_zone', + zonerecord: 'nt_zone_record', + nameserver: 'nt_nameserver', + group: 'nt_group', + user: 'nt_user', + }[resource] +} + +function delegateIdColumn(resource) { + return { + zone: 'o.nt_zone_id', + zonerecord: 'o.nt_zone_record_id', + nameserver: 'o.nt_nameserver_id', + group: 'o.nt_group_id', + user: 'o.nt_user_id', + }[resource] +} + +class AuthzRepoMysql extends AuthzBase { + async getObjectGroupId(resource, objectId) { + const query = RESOURCE_QUERIES[resource] + if (!query) return null + + const rows = await Mysql.execute(query, [objectId]) + if (rows.length === 0) return null + + let gid = rows[0].nt_group_id + if (resource === 'group' && (gid === 0 || gid === null)) gid = 1 + return gid + } + + async isInGroupTree(userGroupId, targetGroupId) { + if (userGroupId === targetGroupId) return true + + const rows = await Mysql.execute( + `SELECT COUNT(*) AS count FROM nt_group_subgroups + WHERE nt_group_id = ? AND nt_subgroup_id = ?`, + [userGroupId, targetGroupId], + ) + return rows[0].count > 0 + } + + async isActiveGroup(groupId) { + const rows = await Mysql.execute('SELECT 1 FROM nt_group WHERE nt_group_id = ? AND deleted = 0', [ + groupId, + ]) + return rows.length > 0 + } + + async isActiveObject(resource, objectId) { + const table = delegateTable(resource) + const idColumn = delegateIdColumn(resource)?.slice(2) + if (!table || !idColumn) return false + const rows = await Mysql.execute(`SELECT 1 FROM ${table} WHERE ${idColumn} = ? AND deleted = 0`, [ + objectId, + ]) + return rows.length > 0 + } + + async getDirectDelegateAccess(groupId, objectId, resource) { + const type = DELEGATE_TYPE[resource] + if (!type) return null + const rows = await Mysql.execute( + `SELECT d.* FROM nt_delegate d + JOIN ${delegateTable(resource)} o ON ${delegateIdColumn(resource)} = d.nt_object_id + WHERE d.nt_group_id = ? AND d.nt_object_id = ? AND d.nt_object_type = ? + AND d.deleted = 0 AND o.deleted = 0`, + [groupId, objectId, type], + ) + return rows.length > 0 ? rows[0] : null + } + + async getDelegatedZoneIds(groupIds) { + const gids = (Array.isArray(groupIds) ? groupIds : [groupIds]).map(Number).filter(Number.isInteger) + if (gids.length === 0) return [] + const placeholders = gids.map(() => '?').join(', ') + const rows = await Mysql.execute( + `SELECT d.nt_object_id AS id + FROM nt_delegate d + JOIN nt_zone z ON z.nt_zone_id = d.nt_object_id + WHERE d.nt_group_id IN (${placeholders}) + AND d.nt_object_type = 'ZONE' AND d.deleted = 0 AND z.deleted = 0 + UNION + SELECT r.nt_zone_id AS id + FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id IN (${placeholders}) + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [...gids, ...gids], + ) + return rows.map((row) => row.id) + } + + async delegatedRecordIdsInZone(groupId, zoneId) { + const rows = await Mysql.execute( + `SELECT r.nt_zone_record_id AS id + FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_record_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? AND r.nt_zone_id = ? + AND d.nt_object_type = 'ZONERECORD' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [groupId, zoneId], + ) + return rows.map((row) => row.id) + } + + async zoneDelegationForRecord(groupId, zoneRecordId) { + const rows = await Mysql.execute( + `SELECT d.*, 1 AS pseudo FROM nt_delegate d + JOIN nt_zone_record r ON r.nt_zone_id = d.nt_object_id + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + WHERE d.nt_group_id = ? + AND r.nt_zone_record_id = ? + AND d.nt_object_type = 'ZONE' + AND d.deleted = 0 AND r.deleted = 0 AND z.deleted = 0`, + [groupId, zoneRecordId], + ) + return rows.length > 0 ? rows[0] : null + } + + async liveSessionGroup(userId, sessionId, oldestSec) { + const rows = await Mysql.execute( + `SELECT u.nt_group_id AS gid + FROM nt_user u + JOIN nt_group g ON g.nt_group_id = u.nt_group_id + JOIN nt_user_session s ON s.nt_user_id = u.nt_user_id + WHERE u.nt_user_id = ? AND s.nt_user_session_id = ? + AND u.deleted = 0 AND g.deleted = 0 + AND s.last_access >= ?`, + [userId, sessionId, oldestSec], + ) + return rows.length === 0 ? null : rows[0].gid + } + + async permissionRecord(permissionId) { + const rows = await Mysql.execute( + `SELECT NULLIF(p.nt_user_id, 0) AS uid, + NULLIF(p.nt_group_id, 0) AS gid, + COALESCE(u.nt_group_id, NULLIF(p.nt_group_id, 0)) AS target_gid + FROM nt_perm p + LEFT JOIN nt_user u ON u.nt_user_id = p.nt_user_id + WHERE p.nt_perm_id = ?`, + [permissionId], + ) + return rows.length === 0 ? null : rows[0] + } +} + +export default AuthzRepoMysql diff --git a/lib/config.js b/lib/config.js index 25655ca..74886a9 100644 --- a/lib/config.js +++ b/lib/config.js @@ -166,6 +166,9 @@ function applyEnvOverrides(name, cfg) { if (name === 'http') { if (process.env.NICTOOL_HTTP_HOST) cfg.host = process.env.NICTOOL_HTTP_HOST if (process.env.NICTOOL_HTTP_PORT) cfg.port = parsePort('NICTOOL_HTTP_PORT') + if (process.env.NICTOOL_HTTP_LIST_LIMIT_MAX) { + cfg.list_limit_max = parseInt(process.env.NICTOOL_HTTP_LIST_LIMIT_MAX, 10) + } } if (name === 'store') { if (process.env.NICTOOL_DATA_STORE) cfg.type = process.env.NICTOOL_DATA_STORE diff --git a/lib/delegation/index.js b/lib/delegation/index.js new file mode 100644 index 0000000..8f0c68c --- /dev/null +++ b/lib/delegation/index.js @@ -0,0 +1,24 @@ +import { storeType } from '../config.js' + +const type = storeType() + +let RepoClass +switch (type) { + case 'json': + case 'toml': + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default + break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break + default: + throw new Error(`delegation: no store implementation for type "${type}"`) +} + +export default new RepoClass() diff --git a/lib/delegation/store/base.js b/lib/delegation/store/base.js new file mode 100644 index 0000000..15ccf6c --- /dev/null +++ b/lib/delegation/store/base.js @@ -0,0 +1,58 @@ +/** + * Delegation domain class – pure contract and cross-cutting logic. + * + * Has zero knowledge of how delegations are persisted. All delegation + * repository classes must extend this class and implement the repo contract. + * + * Repo contract: + * create(args) → { created: true } | { duplicate: true } + * getDelegated(gid, type) → object[] (delegations a group holds) + * getDelegates(oid, type, gid?) → object[] (groups holding a delegation) + * put(args) → true | null (null when nothing to update) + * delete(args) → true | null + * writeLog(data, action) → void + */ +export const PERM_FIELDS = [ + 'perm_write', + 'perm_delete', + 'perm_delegate', + 'zone_perm_add_records', + 'zone_perm_delete_records', +] + +class DelegationBase { + async create(_args) { + throw new Error('create() not implemented by this store') + } + + async getDelegated(_gid, _type) { + throw new Error('getDelegated() not implemented by this store') + } + + async getDelegates(_oid, _type, _gid) { + throw new Error('getDelegates() not implemented by this store') + } + + async put(_args) { + throw new Error('put() not implemented by this store') + } + + async delete(_args) { + throw new Error('delete() not implemented by this store') + } + + async writeLog(_data, _action) { + throw new Error('writeLog() not implemented by this store') + } + + /** Route a public get({gid, oid, type}) call to the right repo method. */ + async get(args) { + const { gid, oid } = args + const type = args.type ?? 'ZONE' + if (oid !== undefined) return this.getDelegates(oid, type, gid) + if (gid !== undefined) return this.getDelegated(gid, type) + return [] + } +} + +export default DelegationBase diff --git a/lib/delegation/store/elasticsearch.js b/lib/delegation/store/elasticsearch.js new file mode 100644 index 0000000..d3c724a --- /dev/null +++ b/lib/delegation/store/elasticsearch.js @@ -0,0 +1,29 @@ +import DelegationBase from './base.js' + +class DelegationRepoElasticsearch extends DelegationBase { + async create(_args) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async getDelegated(_gid, _type) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async getDelegates(_oid, _type, _gid) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async put(_args) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async delete(_args) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } + + async writeLog(_data, _action) { + throw new Error('DelegationRepoElasticsearch is not yet implemented') + } +} + +export default DelegationRepoElasticsearch diff --git a/lib/delegation/store/file.js b/lib/delegation/store/file.js new file mode 100644 index 0000000..bb50265 --- /dev/null +++ b/lib/delegation/store/file.js @@ -0,0 +1,170 @@ +import FileStore from '../../store/file.js' + +import DelegationBase, { PERM_FIELDS } from './base.js' + +const TYPES = { + ZONE: { file: 'zone', idCol: 'nt_zone_id' }, + ZONERECORD: { file: 'zone_record', idCol: 'nt_zone_record_id' }, + NAMESERVER: { file: 'nameserver', idCol: 'nt_nameserver_id' }, + GROUP: { file: 'group', idCol: 'nt_group_id' }, +} + +class DelegationRepoFile extends DelegationBase { + constructor() { + super() + this.file = new FileStore('delegation') + this.logFile = new FileStore('delegate_log') + } + + async _load() { + return this.file.load('delegation') + } + + async _update(mutate) { + return this.file.update('delegation', mutate) + } + + // delegations join against four entity files; each is a separate document, + // so lookups that mysql does with a JOIN happen row-at-a-time here + async _object(type, oid) { + const meta = TYPES[type] + if (!meta) return null + const rows = await new FileStore(meta.file).load(meta.file) + return rows.find((r) => r.id === oid && r.deleted !== true) ?? null + } + + async _activeGroupNames() { + const groups = await new FileStore('group').load('group') + return new Map(groups.filter((g) => g.deleted !== true).map((g) => [g.id, g.name])) + } + + async _present(rows) { + const names = await this._activeGroupNames() + return rows + .filter((row) => names.has(row.gid)) + .map((row) => ({ + nt_group_id: row.gid, + nt_object_id: row.oid, + nt_object_type: row.type, + group_name: names.get(row.gid) ?? '', + delegated_by_id: row.delegated_by_id ?? 0, + delegated_by_name: row.delegated_by_name ?? '', + delegate_write: row.perm_write ? 1 : 0, + delegate_delete: row.perm_delete ? 1 : 0, + delegate_delegate: row.perm_delegate ? 1 : 0, + delegate_add_records: row.zone_perm_add_records ? 1 : 0, + delegate_delete_records: row.zone_perm_delete_records ? 1 : 0, + })) + } + + async create(args) { + const { gid, oid, type } = args + if (!TYPES[type]) return {} + + const row = { + gid, + oid, + type, + delegated_by_id: args.delegated_by_id ?? 0, + delegated_by_name: args.delegated_by_name ?? '', + } + for (const f of PERM_FIELDS) row[f] = args[f] === true + + const created = await this._update((rows) => { + if (rows.some((r) => r.gid === gid && r.oid === oid && r.type === type)) return false + rows.push(row) + return true + }) + if (!created) return { duplicate: true } + + await this.writeLog({ ...row, ...permsToInt(row) }, 'delegated') + + return { created: true } + } + + async getDelegated(gid, type) { + if (!TYPES[type]) return [] + const rows = (await this._load()).filter((r) => r.gid === gid && r.type === type) + const active = [] + for (const row of rows) { + if (await this._object(type, row.oid)) active.push(row) + } + const presented = await this._present(active) + const meta = TYPES[type] + for (const p of presented) p[meta.idCol] = p.nt_object_id + return presented + } + + async getDelegates(oid, type, gid) { + if (!TYPES[type]) return [] + let rows = (await this._load()).filter((r) => r.oid === oid && r.type === type) + if (gid !== undefined) rows = rows.filter((r) => r.gid === gid) + return this._present(rows) + } + + async put(args) { + const { gid, oid, type } = args + const updates = {} + for (const f of PERM_FIELDS) { + if (args[f] !== undefined) updates[f] = args[f] === true + } + + const row = await this._update((rows) => { + const found = rows.find((r) => r.gid === gid && r.oid === oid && r.type === type) + if (found) Object.assign(found, updates) + return found ?? null + }) + if (!row) return null + if (Object.keys(updates).length === 0) return true + + await this.writeLog( + { ...row, delegated_by_id: args.delegated_by_id, delegated_by_name: args.delegated_by_name }, + 'modified', + ) + + return true + } + + async delete(args) { + const { gid, oid, type } = args + const row = await this._update((rows) => { + const at = rows.findIndex((r) => r.gid === gid && r.oid === oid && r.type === type) + return at === -1 ? null : rows.splice(at, 1)[0] + }) + if (!row) return null + + await this.writeLog( + { ...row, delegated_by_id: args.delegated_by_id, delegated_by_name: args.delegated_by_name }, + 'deleted', + ) + + return true + } + + async writeLog(data, action) { + await this.logFile.update('delegate_log', (logs) => + logs.push({ + nt_user_id: data.delegated_by_id ?? 0, + nt_user_name: data.delegated_by_name ?? '', + action, + nt_object_type: data.nt_object_type ?? data.type, + nt_object_id: data.nt_object_id ?? data.oid, + nt_group_id: data.nt_group_id ?? data.gid, + timestamp: Math.floor(Date.now() / 1000), + perm_write: (data.perm_write ?? true) ? 1 : 0, + perm_delete: (data.perm_delete ?? true) ? 1 : 0, + perm_delegate: (data.perm_delegate ?? true) ? 1 : 0, + zone_perm_add_records: (data.zone_perm_add_records ?? true) ? 1 : 0, + zone_perm_delete_records: (data.zone_perm_delete_records ?? true) ? 1 : 0, + }), + ) + } +} + +function permsToInt(row) { + const out = {} + for (const f of PERM_FIELDS) out[f] = row[f] ? 1 : 0 + return out +} + +export default DelegationRepoFile diff --git a/lib/delegation/store/mongodb.js b/lib/delegation/store/mongodb.js new file mode 100644 index 0000000..1de734e --- /dev/null +++ b/lib/delegation/store/mongodb.js @@ -0,0 +1,29 @@ +import DelegationBase from './base.js' + +class DelegationRepoMongoDB extends DelegationBase { + async create(_args) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async getDelegated(_gid, _type) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async getDelegates(_oid, _type, _gid) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async put(_args) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async delete(_args) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } + + async writeLog(_data, _action) { + throw new Error('DelegationRepoMongoDB is not yet implemented') + } +} + +export default DelegationRepoMongoDB diff --git a/lib/delegation/store/mysql.js b/lib/delegation/store/mysql.js new file mode 100644 index 0000000..025b279 --- /dev/null +++ b/lib/delegation/store/mysql.js @@ -0,0 +1,203 @@ +import Mysql from '../../mysql.js' + +import DelegationBase, { PERM_FIELDS } from './base.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' }, +} + +class DelegationRepoMysql extends DelegationBase { + async create(args) { + const { gid, oid, type } = args + + const row = { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + delegated_by_id: args.delegated_by_id ?? 0, + delegated_by_name: args.delegated_by_name ?? '', + } + + for (const f of PERM_FIELDS) { + row[f] = args[f] === true ? 1 : 0 + } + + // nt_delegate has no unique key, so the check and insert must not + // interleave; the lock goes with the connection when the transaction ends + const result = await Mysql.transaction(async (tx) => { + const [lock] = await tx.execute('SELECT GET_LOCK(?, 10) AS acquired', [`ntd:${type}:${oid}:${gid}`]) + if (lock.acquired !== 1) throw new Error(`Could not lock delegation ${type} ${oid} for group ${gid}`) + + const existing = await tx.execute( + `SELECT nt_group_id FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length > 0) return { duplicate: true } + + await tx.execute(...tx.insert('nt_delegate', row)) + return { created: true } + }) + + if (result.created) await this.writeLog(row, 'delegated') + + return result + } + + async getDelegated(gid, objType) { + const meta = TYPE_META[objType] + if (!meta) return [] + + const query = `SELECT + d.nt_group_id, + d.nt_object_id, + d.nt_object_type, + g.name AS group_name, + d.delegated_by_id, + d.delegated_by_name, + d.perm_write AS delegate_write, + d.perm_delete AS delegate_delete, + d.perm_delegate AS delegate_delegate, + d.zone_perm_add_records AS delegate_add_records, + d.zone_perm_delete_records AS delegate_delete_records, + o.${meta.idCol} AS ${meta.idCol} + FROM nt_delegate d + JOIN ${meta.table} o ON o.${meta.idCol} = d.nt_object_id + JOIN nt_group g ON g.nt_group_id = d.nt_group_id + WHERE d.nt_object_type = ? + AND d.nt_group_id = ? + AND d.deleted = 0 + AND o.deleted = 0 + AND g.deleted = 0` + + return Mysql.execute(query, [objType, gid]) + } + + async getDelegates(oid, objType, gid) { + const query = `SELECT + d.nt_group_id, + d.nt_object_id, + d.nt_object_type, + g.name AS group_name, + d.delegated_by_id, + d.delegated_by_name, + d.perm_write AS delegate_write, + d.perm_delete AS delegate_delete, + d.perm_delegate AS delegate_delegate, + d.zone_perm_add_records AS delegate_add_records, + d.zone_perm_delete_records AS delegate_delete_records + FROM nt_delegate d + JOIN nt_group g ON g.nt_group_id = d.nt_group_id + WHERE d.nt_object_type = ? + AND d.nt_object_id = ? + AND d.deleted = 0 + AND g.deleted = 0` + + if (gid === undefined) return Mysql.execute(query, [objType, oid]) + return Mysql.execute(`${query} AND d.nt_group_id = ?`, [objType, oid, gid]) + } + + async put(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT perm_write, perm_delete, perm_delegate, + zone_perm_add_records, zone_perm_delete_records + FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + [gid, oid, type], + ) + if (existing.length === 0) return null + + const updates = {} + for (const f of PERM_FIELDS) { + if (args[f] !== undefined) { + updates[f] = args[f] === true ? 1 : 0 + } + } + if (Object.keys(updates).length === 0) return true + + const setClauses = Object.keys(updates) + .map((k) => `${k} = ?`) + .join(', ') + const values = [...Object.values(updates), gid, oid, type] + + await Mysql.execute( + `UPDATE nt_delegate SET ${setClauses} + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`, + values, + ) + + await this.writeLog( + { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + ...existing[0], + delegated_by_id: args.delegated_by_id, + delegated_by_name: args.delegated_by_name, + ...updates, + }, + 'modified', + ) + + return true + } + + async delete(args) { + const { gid, oid, type } = args + + const existing = await Mysql.execute( + `SELECT 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.writeLog( + { + nt_group_id: gid, + nt_object_id: oid, + nt_object_type: type, + ...existing[0], + delegated_by_id: args.delegated_by_id, + delegated_by_name: args.delegated_by_name, + }, + 'deleted', + ) + + await Mysql.execute( + `DELETE FROM nt_delegate + WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ?`, + [gid, oid, type], + ) + + return true + } + + async writeLog(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 DelegationRepoMysql diff --git a/lib/file-stores.test.js b/lib/file-stores.test.js new file mode 100644 index 0000000..340b928 --- /dev/null +++ b/lib/file-stores.test.js @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { after, before, describe, it } from 'node:test' + +import Config from './config.js' + +// The mysql side of these subsystems is covered by the mysql-backend suite; +// this is the file-store half. +const saved = { + storeType: process.env.NICTOOL_DATA_STORE, + storePath: process.env.NICTOOL_DATA_STORE_PATH, +} + +function freshStores() { + return Promise.all([ + import('./delegation/store/file.js'), + import('./audit/store/file.js'), + import('./authz/store/file.js'), + ]) +} + +before(() => { + process.env.NICTOOL_DATA_STORE = 'json' + process.env.NICTOOL_DATA_STORE_PATH = mkdtempSync(`${tmpdir()}/nt-file-stores-`) + delete Config.cfg.store +}) + +after(() => { + rmSync(process.env.NICTOOL_DATA_STORE_PATH, { recursive: true, force: true }) + if (saved.storeType === undefined) delete process.env.NICTOOL_DATA_STORE + else process.env.NICTOOL_DATA_STORE = saved.storeType + if (saved.storePath === undefined) delete process.env.NICTOOL_DATA_STORE_PATH + else process.env.NICTOOL_DATA_STORE_PATH = saved.storePath +}) + +describe('file-store delegation', () => { + it('creates one delegation when identical requests race', async () => { + const FileDelegation = new (await freshStores().then(([d]) => d.default))() + const args = { gid: 42, oid: 8, type: 'ZONE' } + const results = await Promise.all([1, 2, 3].map(() => FileDelegation.create(args))) + assert.equal(results.filter((r) => r.created).length, 1) + assert.equal(results.filter((r) => r.duplicate).length, 2) + assert.ok(await FileDelegation.delete(args)) + }) + + it('creates, lists, updates, and deletes a delegation', async () => { + const FileDelegation = new (await freshStores().then(([d]) => d.default))() + await seedEntity('group', [{ id: 42, name: 'delegate holder' }]) + const created = await FileDelegation.create({ + gid: 42, + oid: 7, + type: 'ZONE', + perm_write: true, + delegated_by_id: 2, + }) + assert.ok(created.created) + + assert.ok((await FileDelegation.create({ gid: 42, oid: 7, type: 'ZONE' })).duplicate) + + let rows = await FileDelegation.getDelegated(42, 'ZONE') + assert.equal(rows.length, 0) // zone 7 does not exist in the empty entity files + + const delegates = await FileDelegation.getDelegates(7, 'ZONE') + assert.equal(delegates[0].delegate_write, 1) + + assert.ok(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE', perm_write: false })) + assert.equal((await FileDelegation.getDelegates(7, 'ZONE'))[0].delegate_write, 0) + + assert.ok( + await FileDelegation.delete({ + gid: 42, + oid: 7, + type: 'ZONE', + delegated_by_id: 9, + delegated_by_name: 'deleting user', + }), + ) + assert.equal(await FileDelegation.put({ gid: 42, oid: 7, type: 'ZONE' }), null) + assert.deepEqual(await FileDelegation.getDelegates(7, 'ZONE'), []) + + const { default: FileStore } = await import('./store/file.js') + const logs = await new FileStore('delegate_log').load('delegate_log') + assert.deepEqual( + { uid: logs.at(-1).nt_user_id, name: logs.at(-1).nt_user_name }, + { uid: 9, name: 'deleting user' }, + ) + }) +}) + +describe('file-store audit', () => { + it('records and lists entries with search, sort, and pagination', async () => { + const FileAudit = new (await freshStores().then(([, a]) => a.default))() + // the global listing resolves actors through the user file + await seedEntity('user', [ + { + id: 5, + gid: 3, + username: 'auditor', + first_name: 'Audie', + last_name: 'Tor', + }, + ]) + const actor = { id: 5 } + const zone = { + id: 9, + gid: 3, + zone: 'audit.test.', + mailaddr: 'hm.audit.test.', + serial: 1, + ttl: 3600, + } + + await FileAudit.logZone(actor, 'added', zone) + await FileAudit.logZone(actor, 'modified', { ...zone, serial: 2 }, { gid: 1 }) + + const list = await FileAudit.listZones({ gids: [3] }) + assert.equal(list.total, 2) + assert.equal(list.rows[0].action, 'modified') // default sort: newest first + + assert.equal(list.rows[0].user, 'Audie Tor (auditor)') + + const paged = await FileAudit.listZones({ gids: [3], limit: 1, offset: 1 }) + assert.equal(paged.rows.length, 1) + assert.equal(paged.rows[0].action, 'added') + + const searched = await FileAudit.listZones({ gids: [3], search: 'MODIF' }) + assert.equal(searched.filtered, 1) + + const exact = await FileAudit.listZones({ gids: [3], search: 'modified', exact_match: true }) + assert.equal(exact.filtered, 1) + + const partialExact = await FileAudit.listZones({ gids: [3], search: 'mod', exact_match: true }) + assert.equal(partialExact.filtered, 0) + + const global = await FileAudit.listGlobal({ gids: [3] }) + assert.equal(global.total, 2) + assert.equal(global.rows[0].description, 'modified zone') + }) + + it('rejects an unscoped listing', async () => { + const FileAudit = new (await freshStores().then(([, a]) => a.default))() + assert.equal((await FileAudit.listZones({ gids: [] })).total, 0) + }) +}) + +describe('file-store authz', () => { + it('resolves object groups and group trees', async () => { + const FileAuthz = new (await freshStores().then(([, , z]) => z.default))() + await seedEntity('group', [ + { id: 1, name: 'root' }, + { id: 10, name: 'parent', parent_gid: 1 }, + { id: 11, name: 'child', parent_gid: 10 }, + ]) + await seedEntity('zone', [{ id: 20, gid: 11, zone: 'z.test.' }]) + await seedEntity('user', [{ id: 30, gid: 10, username: 'u', first_name: 'U', last_name: 'One' }]) + + assert.equal(await FileAuthz.getObjectGroupId('zone', 20), 11) + assert.equal(await FileAuthz.isInGroupTree(10, 11), true) + assert.equal(await FileAuthz.isInGroupTree(11, 10), false) + assert.equal(await FileAuthz.isActiveGroup(10), true) + assert.equal(await FileAuthz.isActiveObject('user', 30), true) + assert.equal(await FileAuthz.getObjectGroupId('zone', 999), null) + }) + + it('uses the persisted session and permission shapes', async () => { + const FileAuthz = new (await freshStores().then(([, , z]) => z.default))() + await seedEntity('group', [ + { id: 10, name: 'parent', permissions: { id: 10 } }, + { id: 11, name: 'deleted-owner', parent_gid: 10 }, + ]) + await seedEntity('user', [{ id: 30, gid: 10, username: 'u', permissions: { id: 30 } }]) + await seedEntity('session', [{ id: 40, uid: 30, last_access: 2_000_000_000 }]) + await seedEntity('zone', [{ id: 50, gid: 11, zone: 'deleted.test.', deleted: true }]) + + assert.equal(await FileAuthz.liveSessionGroup(30, 40, 1_000_000_000), 10) + assert.deepEqual(await FileAuthz.permissionRecord(30), { + uid: 30, + gid: 10, + target_gid: 10, + }) + assert.equal(await FileAuthz.getObjectGroupId('zone', 50), 11) + }) + + it('honors delegations for record access', async () => { + const FileAuthz = new (await freshStores().then(([, , z]) => z.default))() + const FileDelegation = new (await freshStores().then(([d]) => d.default))() + + await seedEntity('group', [ + { id: 50, name: 'holder' }, + { id: 51, name: 'owner' }, + ]) + await seedEntity('zone', [{ id: 60, gid: 51, zone: 'del.test.' }]) + await seedEntity('zone_record', [{ id: 61, zid: 60, owner: 'a.del.test.' }]) + await FileDelegation.create({ + gid: 50, + oid: 61, + type: 'ZONERECORD', + perm_write: true, + zone_perm_add_records: true, + }) + // a delegation on the zone itself surfaces when acting on its records + await FileDelegation.create({ + gid: 50, + oid: 60, + type: 'ZONE', + perm_write: true, + }) + + assert.deepEqual(await FileAuthz.delegatedRecordIdsInZone(50, 60), [61]) + assert.deepEqual(await FileAuthz.getDelegatedZoneIds([50]), [60]) + const pseudo = await FileAuthz.zonePseudoDelegation(50, 60) + assert.equal(pseudo.pseudo, 1) + const viaRecord = await FileAuthz.zoneDelegationForRecord(50, 61) + assert.equal(viaRecord.pseudo, 1) + assert.equal(viaRecord.perm_write, 1) + }) + + it('applies delegated collection scopes', async () => { + const { default: FileZone } = await import('./zone/store/file.js') + const { default: FileZoneRecord } = await import('./zone_record/store/file.js') + const zones = new FileZone() + const records = new FileZoneRecord() + + await seedEntity('zone', [ + { id: 60, gid: 50, zone: 'owned.test.' }, + { id: 61, gid: 51, zone: 'delegated.test.' }, + { id: 62, gid: 51, zone: 'hidden.test.' }, + ]) + await seedEntity('zone_record', [ + { id: 70, zid: 61, owner: 'allowed.delegated.test.' }, + { id: 71, zid: 61, owner: 'hidden.delegated.test.' }, + ]) + + assert.deepEqual( + (await zones.get({ gid: 50, accessible_ids: [61] })).map((z) => z.id), + [61, 60], + ) + assert.equal(await zones.count({ gid: 50, accessible_ids: [61] }), 2) + assert.deepEqual( + (await records.get({ zid: 61, ids: [70] })).map((r) => r.id), + [70], + ) + assert.equal(await records.count({ zid: 61, ids: [70] }), 1) + assert.deepEqual(await records.get({ zid: 61, ids: [] }), []) + assert.equal(await records.count({ zid: 61, ids: [] }), 0) + }) +}) + +async function seedEntity(name, rows) { + const { default: FileStore } = await import('./store/file.js') + await new FileStore(name).save(name, rows) +} diff --git a/lib/group/store/file.js b/lib/group/store/file.js index a5044c4..268a4bd 100644 --- a/lib/group/store/file.js +++ b/lib/group/store/file.js @@ -1,17 +1,8 @@ import FileStore from '../../store/file.js' +import Permission from '../../permission/index.js' import GroupBase from './base.js' -const defaultPermissions = { - inherit: false, - self_write: false, - group: { create: false, write: false, delete: false }, - nameserver: { usable: [], create: false, write: false, delete: false }, - zone: { create: false, write: false, delete: false, delegate: false }, - zonerecord: { create: false, write: false, delete: false, delegate: false }, - user: { create: false, write: false, delete: false }, -} - class GroupRepoFile extends GroupBase { constructor(args = {}) { super(args) @@ -68,24 +59,16 @@ class GroupRepoFile extends GroupBase { const usable_ns = args.usable_ns ?? [] delete args.usable_ns - const gid = args.id - args.permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: gid, - name: `Group ${args.name} perms`, - user: { id: gid, create: false, write: false, delete: false }, - group: { id: gid, create: false, write: false, delete: false }, - nameserver: { - usable: Array.isArray(usable_ns) ? usable_ns : [], - create: false, - write: false, - delete: false, - }, - } - const groups = await this._load() groups.push(args) await this._save(groups) + + const gid = args.id + await Permission.create({ + gid, + name: `Group ${args.name} perms`, + nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] }, + }) return gid } @@ -136,9 +119,7 @@ class GroupRepoFile extends GroupBase { } } - if (Object.keys(args).length > 0) { - groups[idx] = { ...groups[idx], ...args } - } + if (Object.keys(args).length > 0) groups[idx] = { ...groups[idx], ...args } await this._save(groups) return true diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index 611810e..73dd1f9 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -42,20 +42,26 @@ class Group extends GroupBase { return gid } - async addToSubgroups(gid, parent_gid, rank = 1000) { + async addToSubgroups(gid, parent_gid, rank = 1000, db = Mysql) { if (!parent_gid || parent_gid === 0) return // `rank` is a reserved word in MySQL 8.0+, so it must be backticked; the // generic Mysql.insert helper doesn't quote column names. - await Mysql.execute('INSERT INTO nt_group_subgroups (nt_group_id,nt_subgroup_id,`rank`) VALUES(?,?,?)', [ + await db.execute('INSERT INTO nt_group_subgroups (nt_group_id,nt_subgroup_id,`rank`) VALUES(?,?,?)', [ parent_gid, gid, rank, ]) - const parent = await this.get({ id: parent_gid }) + const parent = await db.execute( + 'SELECT parent_group_id AS parent_gid FROM nt_group WHERE nt_group_id = ?', + [parent_gid], + ) if (parent.length === 1 && parent[0].parent_gid !== 0) { - await this.addToSubgroups(gid, parent[0].parent_gid, rank - 1) + // both the insert and the parent walk must use the same connection: + // reads through the shared connection cannot see this transaction's + // uncommitted writes and would resurrect stale ancestor rows + await this.addToSubgroups(gid, parent[0].parent_gid, rank - 1, db) } } @@ -157,10 +163,44 @@ class Group extends GroupBase { if (Object.keys(args).length === 0) return true - const r = await Mysql.execute( - ...Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)), + const update = () => Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)) + + if (args.parent_gid === undefined) { + const r = await Mysql.execute(...update()) + return r.changedRows === 1 + } + + return Mysql.transaction(async (tx) => { + const r = await tx.execute(...update()) + await this.rebuildSubgroups(id, tx) + return r.changedRows === 1 + }) + } + + async rebuildSubgroups(rootGid, conn = Mysql) { + const db = conn.execute ? conn : Mysql + const groups = await db.execute( + `WITH RECURSIVE descendants AS ( + SELECT nt_group_id AS id, parent_group_id AS parent_gid + FROM nt_group WHERE nt_group_id = ? + 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], ) - return r.changedRows === 1 + if (groups.length === 0) return + + const placeholders = groups.map(() => '?').join(', ') + await db.execute( + `DELETE FROM nt_group_subgroups WHERE nt_subgroup_id IN (${placeholders})`, + groups.map((group) => group.id), + ) + for (const group of groups) { + await this.addToSubgroups(group.id, group.parent_gid, 1000, db) + } } 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/mysql.js b/lib/mysql.js index ee219ff..18000bf 100644 --- a/lib/mysql.js +++ b/lib/mysql.js @@ -48,6 +48,34 @@ class Mysql { return rows } + // The shared connection cannot hold a transaction: concurrent requests + // would interleave statements inside it. Transactions run on their own + // connection and are handed an object with execute/insert/update helpers. + async transaction(fn) { + const cfg = await Config.get('mysql') + const conn = await mysql.createConnection(cfg) + try { + await conn.beginTransaction() + const tx = { + execute: async (query, paramsArray) => { + const [rows] = await conn.execute(query, paramsArray ?? []) + if (/^(REPLACE|INSERT) INTO/.test(query)) return rows.insertId + return rows + }, + insert: (table, params) => this.insert(table, params), + update: (table, where, params) => this.update(table, where, params), + } + const result = await fn(tx) + await conn.commit() + return result + } catch (err) { + await conn.rollback() + throw err + } finally { + await conn.destroy() + } + } + insert(table, params = {}) { return [ `INSERT INTO ${table} (${Object.keys(params).join(',')}) VALUES(${Object.keys(params).map(() => '?')})`, diff --git a/lib/page.js b/lib/page.js new file mode 100644 index 0000000..9a6ed2d --- /dev/null +++ b/lib/page.js @@ -0,0 +1,12 @@ +import Config from './config.js' + +// The default matches the historical route defaults. The ceiling is +// operator-tunable because the right value depends on production data volume. +const DEFAULT_MAX = 1000 + +export async function pageLimit(requested, fallback = DEFAULT_MAX) { + const cfg = await Config.get('http') + const max = + Number.isInteger(cfg.list_limit_max) && cfg.list_limit_max > 0 ? cfg.list_limit_max : DEFAULT_MAX + return Math.min(Math.max(1, Number.isInteger(requested) ? requested : fallback), max) +} diff --git a/lib/page.test.js b/lib/page.test.js new file mode 100644 index 0000000..d116040 --- /dev/null +++ b/lib/page.test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { describe, it, after } from 'node:test' + +import Config from './config.js' +import { pageLimit } from './page.js' + +describe('pageLimit', () => { + const savedEnv = process.env.NICTOOL_HTTP_LIST_LIMIT_MAX + + after(() => { + if (savedEnv === undefined) delete process.env.NICTOOL_HTTP_LIST_LIMIT_MAX + else process.env.NICTOOL_HTTP_LIST_LIMIT_MAX = savedEnv + Config.cfg = {} + }) + + it('applies the default and clamps both ends', async () => { + assert.equal(await pageLimit(undefined), 1000) + assert.equal(await pageLimit(undefined, 50), 50) + assert.equal(await pageLimit(50), 50) + assert.equal(await pageLimit(5000), 1000) + assert.equal(await pageLimit(0), 1) + assert.equal(await pageLimit(-10), 1) + }) + + it('honors the operator ceiling', async () => { + process.env.NICTOOL_HTTP_LIST_LIMIT_MAX = '25' + Config.cfg = {} + assert.equal(await pageLimit(50), 25) + assert.equal(await pageLimit(10), 10) + }) +}) diff --git a/lib/permission/index.js b/lib/permission/index.js index f301ceb..7ed7787 100644 --- a/lib/permission/index.js +++ b/lib/permission/index.js @@ -11,6 +11,12 @@ switch (type) { case 'mysql': RepoClass = (await import('./store/mysql.js')).default break + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break default: throw new Error(`permission: no store implementation for type "${type}"`) } diff --git a/lib/permission/store/elasticsearch.js b/lib/permission/store/elasticsearch.js new file mode 100644 index 0000000..c709597 --- /dev/null +++ b/lib/permission/store/elasticsearch.js @@ -0,0 +1,29 @@ +import PermissionBase from './base.js' + +class PermissionRepoElasticsearch extends PermissionBase { + async create(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async get(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async getGroup(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async put(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async delete(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } + + async destroy(_args) { + throw new Error('PermissionRepoElasticsearch is not yet implemented') + } +} + +export default PermissionRepoElasticsearch diff --git a/lib/permission/store/file.js b/lib/permission/store/file.js index 15e7cdd..15e126e 100644 --- a/lib/permission/store/file.js +++ b/lib/permission/store/file.js @@ -18,7 +18,7 @@ import PermissionBase from './base.js' * * get({ uid }) → inline permissions of that user * get({ gid }) → inline permissions of that group (uid absent) - * get({ id }) → search user → group → standalone by permissions.id + * get({ id }) → search user → standalone → group by permissions.id * getGroup({ uid }) → permissions of the group the user belongs to */ class PermissionRepoFile extends PermissionBase { @@ -53,6 +53,20 @@ class PermissionRepoFile extends PermissionBase { return this.standaloneFile.save('permission', permissions) } + async _nextId() { + const [users, groups, standalone] = await Promise.all([ + this._loadUsers(), + this._loadGroups(), + this._loadStandalone(), + ]) + return ( + [...users, ...groups] + .map((row) => row.permissions?.id) + .concat(standalone.map((row) => row.id)) + .reduce((max, id) => Math.max(max, id ?? 0), 0) + 1 + ) + } + // --------------------------------------------------------------------------- // Post-processing // --------------------------------------------------------------------------- @@ -63,7 +77,9 @@ class PermissionRepoFile extends PermissionBase { // uid/gid are internal storage hints; never expose them in the response delete r.uid delete r.gid - r.deleted = Boolean(r.deleted) + for (const field of ['inherit', 'self_write', 'deleted']) r[field] = Boolean(r[field]) + if (r.user && r.user.id === undefined) r.user.id = null + if (r.group && r.group.id === undefined) r.group.id = null if (r.nameserver && !Array.isArray(r.nameserver.usable)) r.nameserver.usable = [] if (deletedArg === false) delete r.deleted return r @@ -74,7 +90,7 @@ class PermissionRepoFile extends PermissionBase { // --------------------------------------------------------------------------- async create(args) { - args = JSON.parse(JSON.stringify(args)) + args = expandFlatPermissions(JSON.parse(JSON.stringify(args))) const uid = args.uid ?? args.user?.id const gid = args.gid ?? args.group?.id delete args.uid @@ -85,14 +101,15 @@ class PermissionRepoFile extends PermissionBase { const idx = users.findIndex((u) => u.id === uid) if (idx !== -1) { - // Store inline in user.toml using the actual permission data from args - if (!users[idx].permissions) { - const perm = JSON.parse(JSON.stringify(args)) - perm.id = uid - if (!perm.user) perm.user = {} + // Store inline in user.toml using the actual permission data from args; + // a soft-deleted row is replaced, as the mysql store does + const current = users[idx].permissions + if (!current || current.deleted) { + const perm = deepMerge(permissionDefaults(uid, gid ?? users[idx].gid), args) + perm.id = current?.id ?? args.id ?? (await this._nextId()) perm.user.id = uid - if (!perm.group) perm.group = {} perm.group.id = gid ?? users[idx].gid + perm.deleted = false users[idx].permissions = perm } await this._saveUsers(users) @@ -108,11 +125,12 @@ class PermissionRepoFile extends PermissionBase { if (idx !== -1) { // Store inline in group.toml - if (!groups[idx].permissions) { - const perm = JSON.parse(JSON.stringify(args)) - perm.id = gid - if (!perm.group) perm.group = {} + const current = groups[idx].permissions + if (!current || current.deleted) { + const perm = deepMerge(permissionDefaults(null, gid), args) + perm.id = current?.id ?? args.id ?? (await this._nextId()) perm.group.id = gid + perm.deleted = false groups[idx].permissions = perm } await this._saveGroups(groups) @@ -127,11 +145,15 @@ class PermissionRepoFile extends PermissionBase { if (permId === undefined) return undefined const perms = await this._loadStandalone() - if (!perms.find((p) => p.id === permId)) { - const perm = { ...args, id: permId } + const pidx = perms.findIndex((p) => p.id === permId) + if (pidx === -1 || perms[pidx].deleted) { + const perm = deepMerge(permissionDefaults(uid ?? null, gid ?? null), args) + perm.id = permId + perm.deleted = false if (uid !== undefined) perm.uid = uid if (gid !== undefined) perm.gid = gid - perms.push(perm) + if (pidx === -1) perms.push(perm) + else perms[pidx] = perm await this._saveStandalone(perms) } return permId @@ -141,48 +163,40 @@ class PermissionRepoFile extends PermissionBase { args = JSON.parse(JSON.stringify(args)) const deletedArg = args.deleted ?? false + // an inline row is only returned in the deleted state asked for, so a + // deleted explicit permission stops authorizing and getEffective falls + // back to the group + const inline = (perm) => { + if (!perm) return undefined + if (Boolean(perm.deleted) !== Boolean(deletedArg)) return undefined + return this._postProcess(perm, deletedArg) + } + if (args.uid !== undefined) { const users = await this._loadUsers() - const user = users.find((u) => u.id === args.uid) - if (!user?.permissions) return undefined - const perm = this._postProcess(user.permissions, deletedArg) - if (deletedArg === true && perm.deleted !== true) return undefined - return perm + return inline(users.find((u) => u.id === args.uid)?.permissions) } if (args.gid !== undefined) { // group-level lookup: no uid qualifier const groups = await this._loadGroups() - const group = groups.find((g) => g.id === args.gid) - if (!group?.permissions) return undefined - const perm = this._postProcess(group.permissions, deletedArg) - if (deletedArg === true && perm.deleted !== true) return undefined - return perm + return inline(groups.find((g) => g.id === args.gid)?.permissions) } if (args.id !== undefined) { - // Search user.toml by permissions.id. - // NOTE: group.toml is intentionally NOT searched here — group permissions are - // accessed via { gid }. Searching groups would cause false positives after a - // user permission is destroyed, because user and group share the same numeric - // id space (both user 4096 and group 4096 set permissions.id = 4096). + // user rows first, group rows last: explicit ids can collide with + // generated ones (fixtures give user 4096 and group 4096 the same id), + // and the user's own row is the one /permission/{id} means const users = await this._loadUsers() - const user = users.find((u) => u.permissions?.id === args.id) - if (user?.permissions) { - const perm = this._postProcess(user.permissions, deletedArg) - if (deletedArg === true && perm.deleted !== true) return undefined - return perm - } + const fromUser = inline(users.find((u) => u.permissions?.id === args.id)?.permissions) + if (fromUser) return fromUser - // Check standalone permission.toml const perms = await this._loadStandalone() - const found = perms.find((p) => p.id === args.id) - if (found) { - const isDeleted = Boolean(found.deleted) - const wantDeleted = Boolean(deletedArg) - if (isDeleted !== wantDeleted) return undefined - return this._postProcess(found, deletedArg) - } + const fromStandalone = inline(perms.find((p) => p.id === args.id)) + if (fromStandalone) return fromStandalone + + const groups = await this._loadGroups() + return inline(groups.find((g) => g.permissions?.id === args.id)?.permissions) } return undefined @@ -198,11 +212,12 @@ class PermissionRepoFile extends PermissionBase { if (!group?.permissions) return undefined const deletedArg = args.deleted ?? false + if (Boolean(group.permissions.deleted) !== Boolean(deletedArg)) return undefined return this._postProcess(group.permissions, deletedArg) } async put(args) { - args = JSON.parse(JSON.stringify(args)) + args = expandFlatPermissions(JSON.parse(JSON.stringify(args))) if (!args.id) return false const id = args.id delete args.id @@ -272,6 +287,24 @@ class PermissionRepoFile extends PermissionBase { } async destroy(args) { + if (args.uid !== undefined) { + const users = await this._loadUsers() + const uidx = users.findIndex((u) => u.id === args.uid && u.permissions) + if (uidx === -1) return false + delete users[uidx].permissions + await this._saveUsers(users) + return true + } + + if (args.gid !== undefined) { + const groups = await this._loadGroups() + const gidx = groups.findIndex((g) => g.id === args.gid && g.permissions) + if (gidx === -1) return false + delete groups[gidx].permissions + await this._saveGroups(groups) + return true + } + if (!args.id) return false const users = await this._loadUsers() @@ -322,4 +355,44 @@ function deepMerge(target, source) { return result } +function expandFlatPermissions(permission) { + for (const field of ['inherit', 'self_write', 'deleted']) { + if (permission[field] !== undefined) + permission[field] = permission[field] === true || permission[field] === 1 + } + + if (permission.usable_ns !== undefined) { + const usable = permission.usable_ns + let values = [] + if (Array.isArray(usable)) values = usable.map(String) + else if (![undefined, null, ''].includes(usable)) values = String(usable).split(',') + permission.nameserver ??= {} + permission.nameserver.usable = values + delete permission.usable_ns + } + + for (const resource of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { + for (const action of ['create', 'write', 'delete', 'delegate']) { + const field = `${resource}_${action}` + if (permission[field] === undefined) continue + permission[resource] ??= {} + permission[resource][action] = Boolean(permission[field]) + delete permission[field] + } + } + return permission +} + +function permissionDefaults(uid, gid) { + return { + inherit: false, + self_write: false, + group: { id: gid, create: false, write: false, delete: false }, + nameserver: { usable: [], create: false, write: false, delete: false }, + zone: { create: false, write: false, delete: false, delegate: false }, + zonerecord: { create: false, write: false, delete: false, delegate: false }, + user: { id: uid, create: false, write: false, delete: false }, + } +} + export default PermissionRepoFile diff --git a/lib/permission/store/mongodb.js b/lib/permission/store/mongodb.js new file mode 100644 index 0000000..24cd975 --- /dev/null +++ b/lib/permission/store/mongodb.js @@ -0,0 +1,29 @@ +import PermissionBase from './base.js' + +class PermissionRepoMongoDB extends PermissionBase { + async create(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async get(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async getGroup(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async put(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async delete(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } + + async destroy(_args) { + throw new Error('PermissionRepoMongoDB is not yet implemented') + } +} + +export default PermissionRepoMongoDB diff --git a/lib/permission/store/mysql.js b/lib/permission/store/mysql.js index 3f673c2..9c99960 100644 --- a/lib/permission/store/mysql.js +++ b/lib/permission/store/mysql.js @@ -10,6 +10,26 @@ 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 +38,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 +99,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 +108,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 +132,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 +146,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 +178,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 +217,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 +248,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..07bc133 100644 --- a/lib/permission/test/index.js +++ b/lib/permission/test/index.js @@ -10,6 +10,8 @@ import userTestCase from '../../user/test/user.json' with { type: 'json' } import permTestCase from './permission.json' with { type: 'json' } before(async () => { + await Group.create({ id: 1, parent_gid: 0, name: 'root' }) + await Permission.create({ gid: 1, name: 'Root group permissions' }) await Group.create(groupTestCase) await User.create(userTestCase) }) @@ -19,6 +21,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)) }) @@ -48,10 +57,48 @@ describe('permission', function () { }) it('changes a permission', async () => { - assert.ok(await Permission.put({ id: permTestCase.id, name: 'Changed' })) + assert.ok( + await Permission.put({ + id: permTestCase.id, + name: 'Changed', + group_write: 1, + }), + ) const perm = await Permission.get({ id: permTestCase.id }) assert.deepEqual(perm.name, 'Changed') - assert.ok(await Permission.put({ id: permTestCase.id, name: 'Test Permission' })) + assert.equal(perm.group.write, true) + assert.equal(perm.group_write, undefined) + assert.ok( + await Permission.put({ + id: permTestCase.id, + name: 'Test Permission', + group_write: 0, + }), + ) + }) + + it('finds a group permission by its id', async () => { + const gid = 4300 + await Group.destroy({ id: gid }) + await Group.create({ id: gid, parent_gid: groupTestCase.id, name: 'perm-by-id' }) + const gp = await Permission.get({ gid }) + assert.equal((await Permission.get({ id: gp.id }))?.group.id, gid) + await Group.destroy({ id: gid }) + }) + + it('stops applying a deleted explicit permission', async () => { + await Permission.delete({ id: permTestCase.id }) + assert.equal(await Permission.get({ uid: userTestCase.id }), undefined) + const effective = await Permission.getEffective(userTestCase.id) + assert.equal(effective.group.id, groupTestCase.id) + assert.equal(effective.name, `Group ${groupTestCase.name} perms`) + await Permission.delete({ id: permTestCase.id, deleted: 0 }) + }) + + it('reactivates a soft-deleted permission instead of duplicating it', async () => { + await Permission.delete({ id: permTestCase.id }) + assert.equal(await Permission.create(permTestCase), permTestCase.id) + assert.ok(await Permission.get({ id: permTestCase.id })) }) it('deletes a permission', async () => { @@ -65,7 +112,7 @@ describe('permission', function () { it('destroys a permission', async () => { assert.ok(await Permission.destroy({ id: permTestCase.id })) - const p = await Permission.get({ id: permTestCase.id }) + const p = await Permission.get({ uid: userTestCase.id }) assert.equal(p, undefined) }) }) diff --git a/lib/session/store/file.js b/lib/session/store/file.js index d3d959f..48a9379 100644 --- a/lib/session/store/file.js +++ b/lib/session/store/file.js @@ -1,5 +1,21 @@ import FileStore from '../../store/file.js' +let sessionWriteQueue = Promise.resolve() + +async function withSessionWriteLock(fn) { + const previous = sessionWriteQueue + let release + sessionWriteQueue = new Promise((resolve) => { + release = resolve + }) + await previous + try { + return await fn() + } finally { + release() + } +} + // Map legacy nt_* column names to the friendly API names used throughout. function normalizeArgs(args) { if (args.nt_user_session_id !== undefined) { @@ -32,16 +48,14 @@ class SessionRepoFile { async create(args) { args = normalizeArgs(JSON.parse(JSON.stringify(args))) - - const existing = await this.get({ uid: args.uid, session: args.session }) - if (existing) return existing.id - - const sessions = await this._load() - const nextId = sessions.reduce((max, s) => Math.max(max, s.id ?? 0), 0) + 1 - args.id = nextId - sessions.push(args) - await this._save(sessions) - return nextId + return withSessionWriteLock(async () => { + const sessions = await this._load() + const nextId = sessions.reduce((max, s) => Math.max(max, s.id ?? 0), 0) + 1 + args.id = nextId + sessions.push(args) + await this._save(sessions) + return nextId + }) } async get(args) { diff --git a/lib/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/store-access.test.js b/lib/store-access.test.js new file mode 100644 index 0000000..2856b24 --- /dev/null +++ b/lib/store-access.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// Data access goes through lib//store/ per AGENTS.md. Only stores +// may talk to the mysql wrapper; this walks the source tree and +// fails on any other module that reaches for it. +const ROOTS = ['lib', 'routes'] +const SKIP_PATH = (p) => p.split(path.sep).includes('store') || p.endsWith('.test.js') + +function sourceFiles(dir) { + // this test lives in lib/, so the package root is one level up + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', dir) + return readdirSync(root, { recursive: true }) + .filter((f) => f.endsWith('.js')) + .filter((f) => !SKIP_PATH(f)) + .map((f) => path.join(root, f)) +} + +function importSpecifiers(source) { + return [...source.matchAll(/(?:from|import)\s*\(?['"]([^'"]+)['"]/g)].map((m) => m[1]) +} + +describe('store access', () => { + it('keeps mysql access inside store modules', () => { + const offenders = [] + for (const dir of ROOTS) { + for (const file of sourceFiles(dir)) { + if (file.endsWith(`${path.sep}mysql.js`)) continue + const hits = importSpecifiers(readFileSync(file, 'utf8')).filter( + (spec) => (spec === 'mysql2' || spec.endsWith('/mysql.js')) && !spec.includes('store/'), + ) // dispatchers pick a backend from store/ + if (hits.length > 0) offenders.push(`${file}: ${hits.join(', ')}`) + } + } + assert.deepEqual(offenders, []) + }) +}) diff --git a/lib/store-stubs.test.js b/lib/store-stubs.test.js new file mode 100644 index 0000000..22dba5d --- /dev/null +++ b/lib/store-stubs.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { describe, it } from 'node:test' + +const contracts = [ + { + name: 'audit', + methods: [ + 'insertZoneLog', + 'insertZoneRecordLog', + 'insertGlobalLog', + 'listGlobal', + 'listZones', + 'listZoneRecords', + ], + }, + { + name: 'authz', + methods: [ + 'getObjectGroupId', + 'isInGroupTree', + 'isActiveGroup', + 'isActiveObject', + 'getDirectDelegateAccess', + 'getDelegatedZoneIds', + 'delegatedRecordIdsInZone', + 'zoneDelegationForRecord', + 'liveSessionGroup', + 'permissionRecord', + ], + }, + { + name: 'delegation', + methods: ['create', 'getDelegated', 'getDelegates', 'put', 'delete', 'writeLog'], + }, + { + name: 'permission', + methods: ['create', 'get', 'getGroup', 'put', 'delete', 'destroy'], + }, +] + +describe('new subsystem store stubs', () => { + for (const backend of ['mongodb', 'elasticsearch']) { + it(`dispatches all new subsystems to ${backend}`, () => { + const source = `await Promise.all([ + import('./lib/audit/index.js'), + import('./lib/authz/index.js'), + import('./lib/delegation/index.js'), + import('./lib/permission/index.js'), + ])` + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], { + cwd: process.cwd(), + env: { ...process.env, NICTOOL_DATA_STORE: backend }, + encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr) + }) + + for (const contract of contracts) { + it(`${contract.name} has a loud ${backend} stub`, async () => { + const { default: Repo } = await import(`./${contract.name}/store/${backend}.js`) + const repo = new Repo() + for (const method of contract.methods) { + assert.ok(Object.hasOwn(Repo.prototype, method), `${method} must be explicit`) + await assert.rejects(() => repo[method]({}), /not yet implemented/) + } + }) + } + } +}) diff --git a/lib/store/file.js b/lib/store/file.js index 8ec2c9e..2818615 100644 --- a/lib/store/file.js +++ b/lib/store/file.js @@ -20,6 +20,9 @@ const tomlCodec = { const codecs = { json: jsonCodec, toml: tomlCodec } +// one writer per file at a time, whichever FileStore instance holds it +const writeQueues = new Map() + export function resolveCodec(type = storeConfig().type) { return codecs[type] ?? jsonCodec } @@ -68,6 +71,30 @@ export class FileStore { await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, await this.codec.stringify({ [key]: rows })) } + + /** + * Load, mutate in place, save — serialized per file (not re-entrant) so concurrent writers + * cannot overwrite each other's rows. Resolves to whatever mutate returns. + */ + async update(key, mutate) { + const previous = writeQueues.get(this.basename) ?? Promise.resolve() + let release + writeQueues.set( + this.basename, + new Promise((resolve) => { + release = resolve + }), + ) + await previous + try { + const rows = await this.load(key) + const result = await mutate(rows) + await this.save(key, rows) + return result + } finally { + release() + } + } } export default FileStore diff --git a/lib/user/qualified.js b/lib/user/qualified.js new file mode 100644 index 0000000..7970168 --- /dev/null +++ b/lib/user/qualified.js @@ -0,0 +1,8 @@ +export function splitQualifiedUsername(value, defaultGroup) { + const separator = value.indexOf('@') + if (separator === -1) return { username: value, groupName: defaultGroup } + return { + username: value.slice(0, separator), + groupName: value.slice(separator + 1), + } +} diff --git a/lib/user/qualified.test.js b/lib/user/qualified.test.js new file mode 100644 index 0000000..fcf077f --- /dev/null +++ b/lib/user/qualified.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { splitQualifiedUsername } from './qualified.js' + +describe('qualified usernames', () => { + it('uses the configured group for an unqualified username', () => { + assert.deepEqual(splitQualifiedUsername('alice', 'NicTool'), { + username: 'alice', + groupName: 'NicTool', + }) + }) + + it('preserves @ characters in the group name', () => { + assert.deepEqual(splitQualifiedUsername('alice@Operations@London', 'NicTool'), { + username: 'alice', + groupName: 'Operations@London', + }) + }) +}) diff --git a/lib/user/store/file.js b/lib/user/store/file.js index 4ee232e..cf7c761 100644 --- a/lib/user/store/file.js +++ b/lib/user/store/file.js @@ -1,9 +1,26 @@ import FileStore from '../../store/file.js' import Config from '../../config.js' import Credentials from '../credentials.js' +import Permission from '../../permission/index.js' import UserBase from './base.js' +import { splitQualifiedUsername } from '../qualified.js' const boolFields = ['is_admin', 'deleted'] +let userWriteQueue = Promise.resolve() + +async function withUserWriteLock(fn) { + const previous = userWriteQueue + let release + userWriteQueue = new Promise((resolve) => { + release = resolve + }) + await previous + try { + return await fn() + } finally { + release() + } +} // Read the group file directly (never via the Group module) to avoid circular imports. async function loadGroupPerm(groupFile, gid) { @@ -11,14 +28,16 @@ async function loadGroupPerm(groupFile, gid) { return groups.find((g) => g.id === gid)?.permissions ?? null } -const defaultPermissions = { - inherit: false, - self_write: false, - group: { create: false, write: false, delete: false }, - nameserver: { usable: [], create: false, write: false, delete: false }, - zone: { create: false, write: false, delete: false, delegate: false }, - zonerecord: { create: false, write: false, delete: false, delegate: false }, - user: { create: false, write: false, delete: false }, +async function groupIds(groupFile, gid, includeSubgroups) { + if (!includeSubgroups) return [gid] + const groups = await groupFile.load('group') + const ids = [gid] + for (let i = 0; i < ids.length; i += 1) { + for (const group of groups) { + if (group.parent_gid === ids[i] && !ids.includes(group.id)) ids.push(group.id) + } + } + return ids } class UserRepoFile extends UserBase { @@ -52,13 +71,15 @@ class UserRepoFile extends UserBase { } async authenticate(authTry) { - let [username, groupName] = authTry.username.split('@') - if (!groupName) groupName = this.cfg.group ?? 'NicTool' + const { username, groupName } = splitQualifiedUsername(authTry.username, this.cfg.group ?? 'NicTool') + const groups = await this.groupFile.load('group') const users = await this._load() for (const u of users) { if (u.username !== username) continue if (u.deleted) continue + const group = groups.find((g) => g.id === u.gid && g.name === groupName && !g.deleted) + if (!group) continue const { valid, needsUpgrade } = await Credentials.validPassword( authTry.password, @@ -80,7 +101,7 @@ class UserRepoFile extends UserBase { const result = { ...u } for (const f of ['password', 'pass_salt', 'permissions']) delete result[f] - const g = { id: result.gid, name: groupName } + const g = { id: result.gid, name: group.name } delete result.gid return { user: result, group: g } } @@ -92,13 +113,37 @@ class UserRepoFile extends UserBase { const deletedArg = args.deleted ?? false let users = await this._load() + const groups = await this.groupFile.load('group') + const groupNames = new Map(groups.map((group) => [group.id, group.name])) + users = users.map((u) => ({ ...u, group_name: groupNames.get(u.gid) ?? '' })) if (args.id !== undefined) users = users.filter((u) => u.id === args.id) - if (args.gid !== undefined) users = users.filter((u) => u.gid === args.gid) + if (args.gid !== undefined) { + const gids = await groupIds(this.groupFile, args.gid, args.include_subgroups === true) + users = users.filter((u) => gids.includes(u.gid)) + } if (args.username !== undefined) users = users.filter((u) => u.username === args.username) if (deletedArg === false) users = users.filter((u) => !u.deleted) else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) + const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' + if (search) { + users = users.filter((u) => { + const username = u.username.toLowerCase() + return args.exact_match === true ? username === search : username.includes(search) + }) + } + + const sortBy = ['id', 'username', 'email', 'first_name', 'last_name', 'group_name'].includes(args.sort_by) + ? args.sort_by + : 'username' + const direction = args.sort_dir === 'desc' ? -1 : 1 + users.sort((a, b) => `${a[sortBy] ?? ''}`.localeCompare(`${b[sortBy] ?? ''}`) * direction) + + const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 + const limit = Number.isInteger(args.limit) ? Math.max(1, args.limit) : users.length + users = users.slice(offset, offset + limit) + const result = [] for (const u of users) { const r = this._postProcess(u, deletedArg) @@ -122,42 +167,54 @@ class UserRepoFile extends UserBase { let users = await this._load() if (args.id !== undefined) users = users.filter((u) => u.id === args.id) - if (args.gid !== undefined) users = users.filter((u) => u.gid === args.gid) + if (args.gid !== undefined) { + const gids = await groupIds(this.groupFile, args.gid, args.include_subgroups === true) + users = users.filter((u) => gids.includes(u.gid)) + } if (args.username !== undefined) users = users.filter((u) => u.username === args.username) if (deletedArg === false) users = users.filter((u) => !u.deleted) else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) + const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' + if (search) { + users = users.filter((u) => { + const username = u.username.toLowerCase() + return args.exact_match === true ? username === search : username.includes(search) + }) + } + return users.length } async create(args) { - if (args.id) { - const existing = await this.get({ id: args.id }) - if (existing.length === 1) return existing[0].id - } + return withUserWriteLock(async () => { + const users = await this._load() + if (args.id && users.some((user) => user.id === args.id)) return args.id - args = JSON.parse(JSON.stringify(args)) - - const inherit = args.inherit_group_permissions - delete args.inherit_group_permissions + args = JSON.parse(JSON.stringify(args)) + if (args.id === undefined) { + args.id = users.reduce((max, user) => Math.max(max, user.id ?? 0), 0) + 1 + } - if (args.password) { - Object.assign(args, await Credentials.forStorage(args.password, args.pass_salt)) - } + const inherit = args.inherit_group_permissions + delete args.inherit_group_permissions - if (inherit === false) { - args.permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: args.id, - user: { id: args.id, create: false, write: false, delete: false }, - group: { id: args.gid, create: false, write: false, delete: false }, + if (args.password) { + Object.assign(args, await Credentials.forStorage(args.password, args.pass_salt)) } - } - const users = await this._load() - users.push(args) - await this._save(users) - return args.id + users.push(args) + await this._save(users) + if (inherit === false) { + await Permission.create({ + uid: args.id, + gid: args.gid, + inherit: false, + name: `User ${args.username} perms`, + }) + } + return args.id + }) } async put(args) { @@ -172,22 +229,25 @@ class UserRepoFile extends UserBase { delete args.inherit_group_permissions if (inherit === true) { - // Switch to inherited: remove explicit permissions delete users[idx].permissions - } else if (inherit === false && !users[idx].permissions) { - // Switch to explicit: create default permission entry - users[idx].permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: users[idx].id, - user: { id: users[idx].id, create: false, write: false, delete: false }, - group: { id: users[idx].gid, create: false, write: false, delete: false }, - } } else if (inherit === false && users[idx].permissions) { users[idx].permissions.inherit = false } + if (args.gid !== undefined && users[idx].permissions?.group) { + users[idx].permissions.group.id = args.gid + } + users[idx] = { ...users[idx], ...args } await this._save(users) + if (inherit === false && !users[idx].permissions) { + await Permission.create({ + uid: users[idx].id, + gid: users[idx].gid, + inherit: false, + name: `User ${users[idx].username} perms`, + }) + } return true } diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 85b55e6..2aae7d5 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -4,6 +4,7 @@ import Credentials from '../credentials.js' import UserBase from './base.js' import Permission from '../../permission/index.js' import { mapToDbColumn } from '../../util.js' +import { splitQualifiedUsername } from '../qualified.js' const userDbMap = { id: 'nt_user_id', gid: 'nt_group_id' } const boolFields = ['is_admin', 'deleted'] @@ -16,8 +17,7 @@ class UserRepoMySQL extends UserBase { } async authenticate(authTry) { - let [username, groupName] = authTry.username.split('@') - if (!groupName) groupName = this.cfg.group ?? 'NicTool' + const { username, groupName } = splitQualifiedUsername(authTry.username, this.cfg.group ?? 'NicTool') const query = `SELECT u.nt_user_id AS id , u.nt_group_id @@ -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`, }) @@ -106,15 +109,39 @@ class UserRepoMySQL extends UserBase { const include_subgroups = args.include_subgroups === true delete args.include_subgroups - let query = `SELECT email - , first_name - , last_name - , nt_group_id AS gid - , nt_user_id AS id - , username - , email - , deleted - FROM nt_user` + const search = typeof args.search === 'string' ? args.search.trim() : '' + delete args.search + const exactMatch = args.exact_match === true + delete args.exact_match + + const sortByMap = { + id: 'u.nt_user_id', + username: 'u.username', + email: 'u.email', + first_name: 'u.first_name', + last_name: 'u.last_name', + group_name: 'g.name', + } + const sortBy = sortByMap[args.sort_by] ?? 'username' + const sortDir = args.sort_dir === 'desc' ? 'DESC' : 'ASC' + delete args.sort_by + delete args.sort_dir + + const limit = Number.isInteger(args.limit) ? Math.max(1, args.limit) : undefined + delete args.limit + const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 + delete args.offset + + let query = `SELECT u.email + , u.first_name + , u.last_name + , u.nt_group_id AS gid + , u.nt_user_id AS id + , u.username + , u.deleted + , g.name AS group_name + FROM nt_user u + JOIN nt_group g ON g.nt_group_id = u.nt_group_id` const params = [] const where = [] @@ -127,36 +154,44 @@ class UserRepoMySQL extends UserBase { [args.gid], ) const gids = [args.gid, ...subgroupRows.map((r) => r.nt_subgroup_id)] - where.push(`nt_group_id IN (${gids.join(',')})`) + where.push(`u.nt_group_id IN (${gids.join(',')})`) } else { - where.push('nt_group_id = ?') + where.push('u.nt_group_id = ?') params.push(args.gid) } delete args.gid } if (args.id) { - where.push('nt_user_id = ?') + where.push('u.nt_user_id = ?') params.push(args.id) delete args.id } if (args.username) { - where.push('username = ?') + where.push('u.username = ?') params.push(args.username) delete args.username } if (args.deleted !== undefined) { - where.push('deleted = ?') + where.push('u.deleted = ?') params.push(args.deleted === true ? 1 : 0) delete args.deleted } + if (search) { + where.push(`u.username ${exactMatch ? '=' : 'LIKE'} ?`) + params.push(exactMatch ? search : `%${search}%`) + } + if (where.length > 0) { query += ` WHERE ${where.join(' AND ')}` } + query += ` ORDER BY ${sortBy} ${sortDir}` + if (limit !== undefined) query += ` LIMIT ${limit} OFFSET ${offset}` + const rows = await Mysql.execute(query, params) for (const r of rows) { for (const b of boolFields) { @@ -177,16 +212,32 @@ class UserRepoMySQL extends UserBase { args = JSON.parse(JSON.stringify(args)) if (args.deleted === undefined) args.deleted = false + const includeSubgroups = args.include_subgroups === true + delete args.include_subgroups + const params = [] const where = [] + const search = typeof args.search === 'string' ? args.search.trim() : '' + const exactMatch = args.exact_match === true + if (args.id !== undefined) { where.push('nt_user_id = ?') params.push(args.id) } if (args.gid !== undefined) { - where.push('nt_group_id = ?') - params.push(args.gid) + if (includeSubgroups) { + const subgroupRows = await Mysql.execute( + 'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?', + [args.gid], + ) + const gids = [args.gid, ...subgroupRows.map((r) => r.nt_subgroup_id)] + where.push(`nt_group_id IN (${gids.map(() => '?').join(',')})`) + params.push(...gids) + } else { + where.push('nt_group_id = ?') + params.push(args.gid) + } } if (args.username !== undefined) { where.push('username = ?') @@ -196,6 +247,10 @@ class UserRepoMySQL extends UserBase { where.push('deleted = ?') params.push(args.deleted === true ? 1 : 0) } + if (search) { + where.push(`username ${exactMatch ? '=' : 'LIKE'} ?`) + params.push(exactMatch ? search : `%${search}%`) + } let query = 'SELECT COUNT(*) AS count FROM nt_user' if (where.length > 0) query += ` WHERE ${where.join(' AND ')}` @@ -223,6 +278,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`, }) @@ -234,10 +290,20 @@ class UserRepoMySQL extends UserBase { if (Object.keys(args).length === 0) return true - const r = await Mysql.execute( - ...Mysql.update(`nt_user`, `nt_user_id=${id}`, mapToDbColumn(args, userDbMap)), - ) - return r.changedRows === 1 + const userArgs = mapToDbColumn(args, userDbMap) + if (args.gid === undefined) { + const r = await Mysql.execute(...Mysql.update(`nt_user`, `nt_user_id=${id}`, userArgs)) + return r.changedRows === 1 + } + + return Mysql.transaction(async (tx) => { + const r = await tx.execute(...tx.update(`nt_user`, `nt_user_id=${id}`, userArgs)) + await tx.execute('UPDATE nt_perm SET nt_group_id = ? WHERE nt_user_id = ? AND deleted = 0', [ + args.gid, + id, + ]) + return r.changedRows === 1 + }) } async delete(args) { diff --git a/lib/user/test/index.js b/lib/user/test/index.js index 4a25f52..ab1bef9 100644 --- a/lib/user/test/index.js +++ b/lib/user/test/index.js @@ -35,6 +35,7 @@ function sanitize(u) { for (const f of ['password', 'pass_salt', 'permissions', 'inherit_group_permissions', 'deleted']) { delete r[f] } + if (r.gid === groupCase.id) r.group_name = groupCase.name return r } @@ -53,6 +54,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 }) + } }) }) @@ -67,6 +86,47 @@ describe('user', function () { const u = await User.get({ username: userCase.username }) assert.deepEqual(sanitizeActual(u[0]), sanitize(userCase)) }) + + it('searches, sorts, and paginates users', async () => { + const ids = [] + for (const username of ['unit-search-alpha', 'unit-search-beta', 'unit-search-gamma']) { + ids.push( + await User.create({ + ...userCase, + id: undefined, + username, + email: `${username}@example.com`, + }), + ) + } + try { + const exact = await User.get({ + gid: userCase.gid, + search: 'unit-search-beta', + exact_match: true, + }) + assert.deepEqual( + exact.map((u) => u.username), + ['unit-search-beta'], + ) + + const page = await User.get({ + gid: userCase.gid, + search: 'unit-search-', + sort_by: 'username', + sort_dir: 'desc', + limit: 2, + offset: 1, + }) + assert.deepEqual( + page.map((u) => u.username), + ['unit-search-beta', 'unit-search-alpha'], + ) + assert.equal(await User.count({ gid: userCase.gid, search: 'unit-search-' }), 3) + } finally { + for (const id of ids) await User.destroy({ id }) + } + }) }) describe('PUT', function () { @@ -166,5 +226,13 @@ describe('user', function () { }) assert.ok(u) }) + + it('rejects a valid user in the wrong group', async () => { + const u = await User.authenticate({ + username: `${userCase.username}@wrong.example.com`, + password: userCase.password, + }) + assert.equal(u, undefined) + }) }) }) diff --git a/lib/zone/store/base.js b/lib/zone/store/base.js index f65a539..75265eb 100644 --- a/lib/zone/store/base.js +++ b/lib/zone/store/base.js @@ -8,6 +8,12 @@ * put(args) → boolean * delete(args) → boolean * destroy(args) → boolean + * nameserversFor(zid) → object[] ({zone, name, ttl} for the zone's NS) + * nameserverIds(zid) → number[] (nameserver ids assigned to the zone) + * setNameservers(zid, ids) → boolean (replace the zone's assignments) + * + * create() and put() accept `nameservers` (an array of nameserver ids) and + * store it through setNameservers(); it is never a zone column. */ class ZoneBase { constructor(args = {}) { @@ -18,6 +24,18 @@ class ZoneBase { throw new Error('get() not implemented by this repo') } + async nameserversFor(_zid) { + throw new Error('nameserversFor() not implemented by this repo') + } + + async nameserverIds(_zid) { + throw new Error('nameserverIds() not implemented by this repo') + } + + async setNameservers(_zid, _ids) { + throw new Error('setNameservers() not implemented by this repo') + } + async count(_args) { throw new Error('count() not implemented by this repo') } @@ -44,3 +62,14 @@ class ZoneBase { } export default ZoneBase + +export function canonicalZoneName(zone) { + return zone.replace(/\.+$/, '').toLowerCase() +} + +export class ZoneNameConflictError extends Error { + constructor(zone) { + super(`Zone is already taken: ${zone}`) + this.code = 'ZONE_NAME_CONFLICT' + } +} diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index 2db3eed..8066a25 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -1,13 +1,52 @@ import FileStore, { resolveCodec } from '../../store/file.js' -import ZoneBase from './base.js' +import ZoneBase, { canonicalZoneName, ZoneNameConflictError } from './base.js' const zoneDefaults = { minimum: 3600, ttl: 3600, refresh: 86400, retry: 7200, expire: 1209600 } +let zoneWriteQueue = Promise.resolve() + +async function withZoneWriteLock(fn) { + const previous = zoneWriteQueue + let release + zoneWriteQueue = new Promise((resolve) => { + release = resolve + }) + await previous + try { + return await fn() + } finally { + release() + } +} + +function assertZoneNameAvailable(zones, zone, excludeId) { + const canonical = canonicalZoneName(zone) + if ( + zones.some( + (row) => row.id !== excludeId && row.deleted !== true && canonicalZoneName(row.zone) === canonical, + ) + ) { + throw new ZoneNameConflictError(zone) + } +} class ZoneRepoFile extends ZoneBase { constructor(args = {}) { super(args) this.file = new FileStore('zone') + this.nsFile = new FileStore('zone_nameserver') + } + + async _loadNs() { + return this.nsFile.load('zone_nameserver') + } + + // written after the zone file, under the same lock; a crash between the + // two leaves them out of step + async _replaceNs(zid, ids) { + const rows = (await this._loadNs()).filter((m) => m.zid !== zid) + for (const nid of new Set(ids.map(Number))) rows.push({ zid, nid }) + await this.nsFile.save('zone_nameserver', rows) } async _load() { @@ -35,15 +74,17 @@ class ZoneRepoFile extends ZoneBase { } async create(args) { - if (args.id) { - const existing = await this.get({ id: args.id }) - if (existing.length === 1) return existing[0].id - } - - const zones = await this._load() - zones.push(JSON.parse(JSON.stringify(args))) - await this._save(zones) - return args.id + const { nameservers, ...zone } = args + return withZoneWriteLock(async () => { + const zones = await this._load() + if (zone.id && zones.some((z) => z.id === zone.id)) return zone.id + if (zone.deleted !== true) assertZoneNameAvailable(zones, zone.zone) + if (!zone.id) zone.id = zones.reduce((max, z) => Math.max(max, z.id ?? 0), 0) + 1 + zones.push(JSON.parse(JSON.stringify(zone))) + await this._save(zones) + if (Array.isArray(nameservers)) await this._replaceNs(zone.id, nameservers) + return zone.id + }) } async get(args) { @@ -53,14 +94,17 @@ class ZoneRepoFile extends ZoneBase { const { search, zone_like, description_like, sort_by, sort_dir, limit, offset } = args const id = args.id const gid = args.gid + const accessibleIds = Array.isArray(args.accessible_ids) ? args.accessible_ids : [] const zone = args.zone let zones = await this._load() // Direct field filters if (id !== undefined) zones = zones.filter((z) => z.id === id) - if (Array.isArray(gid)) zones = zones.filter((z) => gid.includes(z.gid)) - else if (gid !== undefined) zones = zones.filter((z) => z.gid === gid) + if (gid !== undefined) { + const gids = Array.isArray(gid) ? gid : [gid] + zones = zones.filter((z) => gids.includes(z.gid) || accessibleIds.includes(z.id)) + } if (zone !== undefined) zones = zones.filter((z) => z.zone === zone) if (deletedArg === false) zones = zones.filter((z) => !z.deleted) else if (deletedArg !== undefined) zones = zones.filter((z) => Boolean(z.deleted) === Boolean(deletedArg)) @@ -108,12 +152,15 @@ class ZoneRepoFile extends ZoneBase { const { search, zone_like, description_like } = args const id = args.id const gid = args.gid + const accessibleIds = Array.isArray(args.accessible_ids) ? args.accessible_ids : [] let zones = await this._load() if (id !== undefined) zones = zones.filter((z) => z.id === id) - if (Array.isArray(gid)) zones = zones.filter((z) => gid.includes(z.gid)) - else if (gid !== undefined) zones = zones.filter((z) => z.gid === gid) + if (gid !== undefined) { + const gids = Array.isArray(gid) ? gid : [gid] + zones = zones.filter((z) => gids.includes(z.gid) || accessibleIds.includes(z.id)) + } if (deletedArg === false) zones = zones.filter((z) => !z.deleted) else if (deletedArg !== undefined) zones = zones.filter((z) => Boolean(z.deleted) === Boolean(deletedArg)) @@ -137,13 +184,18 @@ class ZoneRepoFile extends ZoneBase { async put(args) { if (!args.id) return false - const zones = await this._load() - const idx = zones.findIndex((z) => z.id === args.id) - if (idx === -1) return false + const { nameservers, ...fields } = args + return withZoneWriteLock(async () => { + const zones = await this._load() + const idx = zones.findIndex((z) => z.id === fields.id) + if (idx === -1) return false + if (fields.deleted === false) assertZoneNameAvailable(zones, zones[idx].zone, fields.id) - zones[idx] = { ...zones[idx], ...args } - await this._save(zones) - return true + zones[idx] = { ...zones[idx], ...fields } + await this._save(zones) + if (Array.isArray(nameservers)) await this._replaceNs(fields.id, nameservers) + return true + }) } async delete(args) { @@ -157,12 +209,47 @@ class ZoneRepoFile extends ZoneBase { } async destroy(args) { + return withZoneWriteLock(async () => { + const zones = await this._load() + const before = zones.length + const filtered = zones.filter((z) => z.id !== args.id) + if (filtered.length === before) return false + await this._save(filtered) + await this._replaceNs(args.id, []) + return true + }) + } + + async nameserverIds(zid) { + return (await this._loadNs()) + .filter((m) => m.zid === zid) + .map((m) => m.nid) + .sort((a, b) => a - b) + } + + async setNameservers(zid, ids) { + return withZoneWriteLock(async () => { + await this._replaceNs(zid, ids) + return true + }) + } + + // the zone<->nameserver mapping is its own document; a deployment that + // hasn't created it simply has no NS records to serve + async nameserversFor(zid) { + const mappings = await new FileStore('zone_nameserver').load('zone_nameserver') const zones = await this._load() - const before = zones.length - const filtered = zones.filter((z) => z.id !== args.id) - if (filtered.length === before) return false - await this._save(filtered) - return true + const nameservers = await new FileStore('nameserver').load('nameserver') + + return mappings + .filter((m) => m.zid === zid) + .map((m) => ({ + zone: zones.find((z) => z.id === zid)?.zone, + name: nameservers.find((n) => n.id === m.nid)?.name, + ttl: nameservers.find((n) => n.id === m.nid)?.ttl, + })) + .filter((row) => row.name !== undefined) + .sort((a, b) => (a.name < b.name ? -1 : 1)) } } diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index d6a1b78..ea94d85 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -1,17 +1,49 @@ +import { createHash } from 'node:crypto' + import Mysql from '../../mysql.js' -import ZoneBase from './base.js' +import ZoneBase, { canonicalZoneName, ZoneNameConflictError } from './base.js' import { mapToDbColumn } from '../../util.js' const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' } const boolFields = ['deleted'] -// include_subgroups passes gid as a list of group ids; filter with IN(...). -function applyGidList(query, params, gidList) { - if (!gidList) return [query, params] +function zoneLockName(zone) { + const digest = createHash('sha256').update(canonicalZoneName(zone)).digest('hex') + return `ntz:${digest.slice(0, 60)}` +} + +async function assertZoneNameAvailable(db, zone, excludeId) { + let query = `SELECT nt_zone_id AS id FROM nt_zone + WHERE deleted = 0 AND LOWER(TRIM(TRAILING '.' FROM zone)) = ?` + const params = [canonicalZoneName(zone)] + if (excludeId !== undefined) { + query += ' AND nt_zone_id <> ?' + params.push(excludeId) + } + query += ' LIMIT 1' + if ((await db.execute(query, params)).length > 0) throw new ZoneNameConflictError(zone) +} + +async function withZoneNameLock(zone, fn) { + return Mysql.transaction(async (tx) => { + const [lock] = await tx.execute('SELECT GET_LOCK(?, 10) AS acquired', [zoneLockName(zone)]) + if (lock.acquired !== 1) throw new Error(`Could not lock zone name: ${zone}`) + return fn(tx) + }) +} + +function applyAccessScope(query, params, gidScope, accessibleIds) { + if (gidScope === undefined) return [query, params] + const gidList = Array.isArray(gidScope) ? gidScope : [gidScope] 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 = {}) { @@ -56,15 +88,27 @@ class ZoneRepoMySQL extends ZoneBase { if (g.length === 1) return g[0].id } - return await Mysql.execute(...Mysql.insert(`nt_zone`, mapToDbColumn(args, zoneDbMap))) + const { nameservers, ...zone } = args + + return withZoneNameLock(zone.zone, async (tx) => { + if (zone.deleted !== true) await assertZoneNameAvailable(tx, zone.zone) + const insertId = await tx.execute(...tx.insert(`nt_zone`, mapToDbColumn(zone, zoneDbMap))) + // an explicit-id insert can report insertId 0 (see the group store), + // and id 0 in the payload means auto-increment + const id = zone.id || insertId + if (Array.isArray(nameservers)) await this.setNameservers(id, nameservers, tx) + return id + }) } async get(args) { 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 +157,7 @@ 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 +195,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 +216,7 @@ 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 } @@ -179,6 +225,39 @@ class ZoneRepoMySQL extends ZoneBase { if (!args.id) return false const id = args.id delete args.id + + const nameservers = args.nameservers + delete args.nameservers + + if (Object.keys(args).length === 0) { + if (!Array.isArray(nameservers)) return false + await Mysql.transaction((tx) => this.setNameservers(id, nameservers, tx)) + return true + } + + if (args.deleted === false) { + const rows = await Mysql.execute('SELECT zone FROM nt_zone WHERE nt_zone_id = ? LIMIT 1', [id]) + if (rows.length === 0) return false + return withZoneNameLock(rows[0].zone, async (tx) => { + await assertZoneNameAvailable(tx, rows[0].zone, id) + const r = await tx.execute( + ...tx.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), + ) + if (Array.isArray(nameservers)) await this.setNameservers(id, nameservers, tx) + return r.changedRows === 1 + }) + } + + if (Array.isArray(nameservers)) { + return Mysql.transaction(async (tx) => { + const r = await tx.execute( + ...tx.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), + ) + await this.setNameservers(id, nameservers, tx) + return r.affectedRows === 1 + }) + } + const r = await Mysql.execute( ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), ) @@ -195,13 +274,47 @@ class ZoneRepoMySQL extends ZoneBase { } async destroy(args) { + // no foreign key cleans the assignments up + await Mysql.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [args.id]) const r = await Mysql.execute(...Mysql.delete(`nt_zone`, { nt_zone_id: args.id })) return r.affectedRows === 1 } + async nameserverIds(zid) { + const rows = await Mysql.execute( + 'SELECT nt_nameserver_id AS id FROM nt_zone_nameserver WHERE nt_zone_id = ? ORDER BY nt_nameserver_id', + [zid], + ) + return rows.map((r) => r.id) + } + + async setNameservers(zid, ids, db = Mysql) { + await db.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [zid]) + for (const nid of new Set(ids.map(Number))) { + await db.execute('INSERT INTO nt_zone_nameserver (nt_zone_id, nt_nameserver_id) VALUES (?, ?)', [ + zid, + nid, + ]) + } + return true + } + disconnect() { return this.mysql?.disconnect() } + + async nameserversFor(zid) { + return Mysql.execute( + `SELECT z.zone, n.name, n.ttl + FROM nt_zone_nameserver nzns + JOIN nt_nameserver n ON n.nt_nameserver_id = nzns.nt_nameserver_id + JOIN nt_zone z ON z.nt_zone_id = nzns.nt_zone_id + WHERE nzns.nt_zone_id = ? + ORDER BY n.name`, + [zid], + ) + } } +export { zoneLockName } export default ZoneRepoMySQL diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index d15548b..0d65a80 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -35,6 +35,56 @@ describe('zone', function () { assert.ok(await Zone.put({ id: testCase.id, mailaddr: testCase.mailaddr })) }) + describe('nameservers', () => { + it('starts with no assignment', async () => { + assert.deepEqual(await Zone.nameserverIds(testCase.id), []) + }) + + it('assigns nameservers alongside other fields', async () => { + assert.ok(await Zone.put({ id: testCase.id, description: 'ns', nameservers: [4096, 4095, 4096] })) + assert.deepEqual(await Zone.nameserverIds(testCase.id), [4095, 4096]) + const z = await Zone.get({ id: testCase.id }) + assert.equal(z[0].description, 'ns') + assert.equal(z[0].nameservers, undefined) + }) + + it('replaces the assignment on its own', async () => { + assert.ok(await Zone.put({ id: testCase.id, nameservers: [4096] })) + assert.deepEqual(await Zone.nameserverIds(testCase.id), [4096]) + }) + + it('leaves the assignment alone when absent', async () => { + await Zone.put({ id: testCase.id, description: testCase.description }) + assert.deepEqual(await Zone.nameserverIds(testCase.id), [4096]) + }) + + it('clears the assignment with an empty list', async () => { + assert.ok(await Zone.put({ id: testCase.id, nameservers: [] })) + assert.deepEqual(await Zone.nameserverIds(testCase.id), []) + }) + + it('assigns nameservers to an allocated id', async () => { + const id = await Zone.create({ + ...testCase, + id: 0, + zone: 'auto-ns.example.com', + nameservers: [4095], + }) + assert.ok(id > 0) + assert.deepEqual(await Zone.nameserverIds(id), [4095]) + assert.ok(await Zone.destroy({ id })) + }) + + it('stores the assignment on create and drops it on destroy', async () => { + const id = testCase.id + 1 + await Zone.destroy({ id }) + await Zone.create({ ...testCase, id, zone: 'ns.example.com', nameservers: [4095] }) + assert.deepEqual(await Zone.nameserverIds(id), [4095]) + assert.ok(await Zone.destroy({ id })) + assert.deepEqual(await Zone.nameserverIds(id), []) + }) + }) + describe('deletes a zone', async () => { it('can delete a zone', async () => { assert.ok(await Zone.delete({ id: testCase.id })) diff --git a/lib/zone/test/mysql.js b/lib/zone/test/mysql.js index b8ac9fc..8cee95a 100644 --- a/lib/zone/test/mysql.js +++ b/lib/zone/test/mysql.js @@ -2,11 +2,16 @@ import assert from 'node:assert/strict' import { describe, it, before, after } from 'node:test' import Zone from '../index.js' +import { zoneLockName } from '../store/mysql.js' import baseCase from './zone.json' with { type: 'json' } -// Use a distinct id so this test never races with index.js (same fixture id = concurrent NULL mutation) -const testCase = { ...baseCase, id: 9001 } +const testCase = { + ...baseCase, + id: 9001, + zone: 'mysql-zone.example.com', + mailaddr: 'hostmaster.mysql-zone.example.com.', +} before(async () => { await Zone.destroy({ id: testCase.id }) @@ -19,6 +24,13 @@ after(async () => { }) describe('zone (mysql)', function () { + it('uses a canonical advisory lock name within the DB limit', () => { + const lockName = zoneLockName('Example.COM.') + assert.equal(Buffer.byteLength(lockName), 64) + assert.equal(lockName, zoneLockName('example.com')) + assert.notEqual(lockName, zoneLockName('other.example.com')) + }) + it('handles null minimum gracefully', async () => { await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) diff --git a/lib/zone_record/store/file.js b/lib/zone_record/store/file.js index 4ff5651..15e781f 100644 --- a/lib/zone_record/store/file.js +++ b/lib/zone_record/store/file.js @@ -47,6 +47,7 @@ class ZoneRecordRepoFile extends ZoneRecordBase { if (args.id !== undefined) records = records.filter((r) => r.id === args.id) if (args.zid !== undefined) records = records.filter((r) => r.zid === args.zid) + if (Array.isArray(args.ids)) records = records.filter((r) => args.ids.includes(r.id)) if (args.type !== undefined) records = records.filter((r) => r.type === args.type) if (deletedArg === false) records = records.filter((r) => !r.deleted) else if (deletedArg !== undefined) @@ -65,7 +66,19 @@ class ZoneRecordRepoFile extends ZoneRecordBase { const search = typeof args.search === 'string' ? args.search.trim().toLowerCase() : '' const hasSort = args.sort_by !== undefined || args.sort_dir !== undefined - const sortBy = ['id', 'owner', 'type', 'ttl'].includes(args.sort_by) ? args.sort_by : 'owner' + const sortable = [ + 'id', + 'owner', + 'type', + 'ttl', + 'address', + 'weight', + 'priority', + 'other', + 'description', + 'location', + ] + const sortBy = sortable.includes(args.sort_by) ? args.sort_by : 'owner' const dir = args.sort_dir === 'desc' ? -1 : 1 const limit = Number.isInteger(args.limit) ? args.limit : undefined const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 @@ -74,6 +87,7 @@ class ZoneRecordRepoFile extends ZoneRecordBase { if (args.id !== undefined) records = records.filter((r) => r.id === args.id) if (args.zid !== undefined) records = records.filter((r) => r.zid === args.zid) + if (Array.isArray(args.ids)) records = records.filter((r) => args.ids.includes(r.id)) if (args.type !== undefined) records = records.filter((r) => r.type === args.type) if (args.deleted === false) records = records.filter((r) => !r.deleted) else if (args.deleted !== undefined) diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index 3b4ed24..3d2acf7 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -10,7 +10,18 @@ const boolFields = ['deleted'] const keepZeroWeightFor = new Set(['SRV', 'URI']) const keepZeroPriorityFor = new Set(['HTTPS', 'SVCB', 'URI']) -const sortByColumn = { id: 'nt_zone_record_id', owner: 'name', type: 'type_id', ttl: 'ttl' } +const sortByColumn = { + id: 'nt_zone_record_id', + owner: 'name', + type: 'type_id', + ttl: 'ttl', + address: 'address', + weight: 'weight', + priority: 'priority', + other: 'other', + description: 'description', + location: 'location', +} function applyZoneRecordSearch(query, params, search) { const term = typeof search === 'string' ? search.trim() : '' @@ -20,6 +31,14 @@ 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 +69,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 +103,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 +133,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 +142,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..30099d7 100644 --- a/lib/zone_record/test/index.js +++ b/lib/zone_record/test/index.js @@ -34,6 +34,36 @@ 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/package.json b/package.json index 0406711..0c8abfd 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@hapi/vision": "^7.0.3", "@msimerson/hapi-openapi": "^18.0.3", "@nictool/dns-resource-record": "^1.8.2", - "@nictool/validate": "^0.9.1", + "@nictool/validate": "^1.0.0", "joi": "^18.2.3", "mysql2": "^3.23.2", "qs": "^6.15.3", diff --git a/routes/authz.test.js b/routes/authz.test.js new file mode 100644 index 0000000..46fce77 --- /dev/null +++ b/routes/authz.test.js @@ -0,0 +1,1789 @@ +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 Audit from '../lib/audit/index.js' +import Delegation from '../lib/delegation/index.js' + +const G_ROOT = { + id: 4200, + parent_gid: 0, + name: 'authz-root', +} +const G_CHILD = { + id: 4201, + parent_gid: 4200, + name: 'authz-child', +} +const G_OUTSIDE = { + id: 4202, + parent_gid: 0, + name: 'authz-outside', +} + +const PASSWORD = 'Wh@tA-Decent#P6ssw0rd' + +const U_FULL = { + id: 4200, + gid: 4200, + username: 'authz-full', + email: 'authz-full@example.com', + password: PASSWORD, + first_name: 'Full', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_LIMITED = { + id: 4201, + gid: 4202, + username: 'authz-limited', + email: 'authz-limited@example.com', + password: PASSWORD, + first_name: 'Limited', + last_name: 'Perm', + inherit_group_permissions: false, +} +const U_CREATED = { + id: 4211, + gid: 4201, + username: 'authz-created', + email: 'authz-created@example.com', + password: PASSWORD, + first_name: 'Created', + last_name: 'User', + inherit_group_permissions: false, +} + +const Z_INTREE = { + id: 4200, + gid: 4200, + zone: 'authz.example.com.', + mailaddr: 'hostmaster.authz.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} +const Z_OUTSIDE = { + id: 4201, + gid: 4202, + zone: 'authz-out.example.com.', + mailaddr: 'hostmaster.authz-out.example.com.', + serial: 1, + refresh: 3600, + retry: 900, + expire: 604800, + minimum: 86400, + ttl: 3600, +} + +const ZR_INTREE = { + id: 4200, + zid: 4200, + owner: 'test.authz.example.com.', + type: 'A', + address: '192.0.2.1', + ttl: 3600, +} +const ZR_OUTSIDE = { + id: 4201, + zid: 4201, + owner: 'test.authz-out.example.com.', + type: 'A', + address: '192.0.2.2', + ttl: 3600, +} + +const ZR_INTREE_OTHER = { + id: 4203, + zid: 4200, + owner: 'other.authz.example.com.', + type: 'A', + address: '192.0.2.3', + ttl: 3600, +} + +const ZR_DELEGATED_CREATE = { + id: 4210, + zid: 4201, + owner: 'created.authz-out.example.com.', + type: 'A', + address: '192.0.2.10', + ttl: 3600, +} + +const NS = { + id: 4200, + gid: 4200, + name: 'ns1.authz.example.com.', + type: 'bind', + ttl: 3600, + description: 'authz test ns', + address: '192.0.2.10', + export: { interval: 0, serials: 0 }, +} + +let server +const authFull = { headers: {} } +const authLimited = { headers: {} } + +before(async () => { + // Clean up stale data from prior crashed runs + try { + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + } catch { + /* ignore */ + } + try { + await Delegation.delete({ gid: 4201, oid: 4200, type: 'ZONE' }) + } catch { + /* ignore */ + } + try { + await Delegation.delete({ gid: 4201, oid: 4201, type: 'ZONE' }) + } catch { + /* ignore */ + } + await ZoneRecord.destroy({ id: ZR_DELEGATED_CREATE.id }) + for (const id of [4200, 4201, ZR_INTREE_OTHER.id, U_CREATED.id]) { + await ZoneRecord.destroy({ id }) + await Zone.destroy({ id }) + } + await Nameserver.destroy({ id: 4200 }) + for (const id of [4200, 4201]) { + const p = await Permission.get({ uid: id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id }) + } + for (const id of [4201, 4202, 4200]) await Group.destroy({ id }) + + for (const g of [G_ROOT, G_CHILD, G_OUTSIDE]) await Group.create(g) + for (const u of [U_FULL, U_LIMITED]) await User.create(u) + + // Full permissions for user 4200 + const fullPerm = await Permission.get({ uid: U_FULL.id }) + if (fullPerm) { + await Permission.put({ + id: fullPerm.id, + self_write: 1, + group_write: 1, + group_create: 1, + group_delete: 1, + zone_write: 1, + zone_create: 1, + zone_delete: 1, + zone_delegate: 1, + zonerecord_write: 1, + zonerecord_create: 1, + zonerecord_delete: 1, + zonerecord_delegate: 1, + user_write: 1, + user_create: 1, + user_delete: 1, + nameserver_write: 1, + nameserver_create: 1, + nameserver_delete: 1, + usable_ns: '4200', + }) + } + + // No permissions for user 4201 + const limPerm = await Permission.get({ uid: U_LIMITED.id }) + if (limPerm) { + await Permission.put({ + id: limPerm.id, + self_write: 0, + group_write: 0, + group_create: 0, + group_delete: 0, + zone_write: 0, + zone_create: 0, + zone_delete: 0, + zone_delegate: 0, + zonerecord_write: 0, + zonerecord_create: 0, + zonerecord_delete: 0, + zonerecord_delegate: 0, + user_write: 0, + user_create: 0, + user_delete: 0, + nameserver_write: 0, + nameserver_create: 0, + nameserver_delete: 0, + usable_ns: '', + }) + } + + await Zone.create(Z_INTREE) + await Zone.create(Z_OUTSIDE) + await ZoneRecord.create(ZR_INTREE) + await ZoneRecord.create(ZR_INTREE_OTHER) + await ZoneRecord.create(ZR_OUTSIDE) + await Nameserver.create(NS) + + // Delegation: zone 4201 → group 4200, write=yes delete=no + await Delegation.create({ + gid: 4200, + oid: 4201, + type: 'ZONE', + perm_write: true, + perm_delete: false, + perm_delegate: true, + }) + + server = await init() + + // Login full-perm user + const r1 = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${U_FULL.username}@${G_ROOT.name}`, + password: PASSWORD, + }, + }) + assert.equal(r1.statusCode, 200, `full login failed: ${JSON.stringify(r1.result)}`) + authFull.headers = { + Authorization: `Bearer ${r1.result.session.token}`, + } + + // Login limited user + const r2 = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${U_LIMITED.username}@${G_OUTSIDE.name}`, + password: PASSWORD, + }, + }) + assert.equal(r2.statusCode, 200, `limited login failed: ${JSON.stringify(r2.result)}`) + authLimited.headers = { + Authorization: `Bearer ${r2.result.session.token}`, + } +}) + +after(async () => { + await server.stop() + await Delegation.delete({ gid: 4200, oid: 4201, type: 'ZONE' }) + await Delegation.delete({ gid: 4201, oid: 4200, type: 'ZONE' }) + await Delegation.delete({ gid: 4201, oid: 4201, type: 'ZONE' }) + await ZoneRecord.destroy({ id: ZR_DELEGATED_CREATE.id }) + await Nameserver.destroy({ id: NS.id }) + await ZoneRecord.destroy({ id: ZR_OUTSIDE.id }) + await ZoneRecord.destroy({ id: ZR_INTREE_OTHER.id }) + await ZoneRecord.destroy({ id: ZR_INTREE.id }) + await Zone.destroy({ id: Z_OUTSIDE.id }) + await Zone.destroy({ id: Z_INTREE.id }) + for (const u of [U_LIMITED, U_FULL]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + const createdPerm = await Permission.get({ uid: U_CREATED.id }) + if (createdPerm) await Permission.destroy({ id: createdPerm.id }) + await User.destroy({ id: U_CREATED.id }) + for (const g of [G_CHILD, G_OUTSIDE, G_ROOT]) { + await Group.destroy({ id: g.id }) + } + await Group.disconnect() +}) + +describe('authz plugin - zone routes', () => { + it('200 for GET /zone/{id} with full-perm user (in-tree)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('200 for GET /zone/{id} with full-perm user (delegated)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for GET /zone/{id} with limited user (out of tree)', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone/${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + assert.ok(res.result.error_code) + }) + + it('GET /zone defaults to the caller group', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone', + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual( + res.result.zone.map((z) => z.id), + [Z_OUTSIDE.id], + ) + assert.equal(res.result.meta.pagination.total, 1) + }) + + it('403 for GET /zone scoped outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone?gid=${G_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for zone logs scoped outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/log/zone?gid=${G_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for record logs on a zone outside the caller tree', async () => { + const res = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for POST /zone when user lacks zone.create', async () => { + const res = await server.inject({ + method: 'POST', + 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 when moving a zone outside the caller tree', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { gid: G_OUTSIDE.id }, + }) + assert.equal(res.statusCode, 403) + + const [zone] = await Zone.get({ id: Z_INTREE.id }) + assert.equal(zone.gid, G_ROOT.id) + }) + + it('403 when a delegate moves the delegated zone into its own tree', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + payload: { gid: G_ROOT.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /delegated/) + + const [zone] = await Zone.get({ id: Z_OUTSIDE.id }) + assert.equal(zone.gid, G_OUTSIDE.id) + }) + + it('200 when a delegate edits the delegated zone without moving it', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + payload: { gid: G_OUTSIDE.id, ttl: 7200 }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.zone[0].gid, G_OUTSIDE.id) + }) + + it('rejects unknown zone fields before reaching the store', async () => { + const [before] = await Zone.get({ id: Z_INTREE.id }) + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { ttl: 7201, serial: 7, malicious: 'not-a-column' }, + }) + assert.equal(res.statusCode, 400) + + const [zone] = await Zone.get({ id: Z_INTREE.id }) + assert.equal(zone.ttl, before.ttl) + assert.equal(zone.serial, before.serial) + }) + + it('403 for POST /zone when the requested id already exists', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: authFull.headers, + payload: { ...Z_OUTSIDE, gid: G_ROOT.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /already exists/) + }) + + it('requires delete permission when PUT changes deleted state', async () => { + const perm = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: perm.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + headers: authFull.headers, + payload: { deleted: true }, + }) + assert.equal(res.statusCode, 403) + assert.equal((await Zone.get({ id: Z_INTREE.id })).length, 1) + } finally { + await Permission.put({ id: perm.id, zone_delete: true }) + } + }) + + it('403 for DELETE /zone/{id} with delegated perm_delete=0', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/zone/${Z_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) +}) + +describe('authz plugin - user self-ops', () => { + it('403 when moving yourself to another group', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { gid: G_CHILD.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Cannot move yourself/) + assert.equal((await User.get({ id: U_FULL.id }))[0].gid, G_ROOT.id) + }) + + it('rejects unknown fields before self-write reaches the store', async () => { + const [before] = await User.get({ id: U_FULL.id }) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { first_name: 'Rejected Full', malicious: 'not-a-column' }, + }) + assert.equal(res.statusCode, 400) + assert.equal((await User.get({ id: U_FULL.id }))[0].first_name, before.first_name) + }) + + it('does not pass is_admin through self-write', async () => { + const [before] = await User.get({ id: U_FULL.id }) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { + first_name: 'Still Full', + is_admin: true, + }, + }) + assert.equal(res.statusCode, 200) + + const [user] = await User.get({ id: U_FULL.id }) + assert.equal(user.gid, G_ROOT.id) + assert.equal(user.is_admin, before.is_admin) + assert.equal(user.first_name, 'Still Full') + }) + + it('403 for DELETE /user/{self}', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to delete self/) + }) + + it('403 for PUT /user/{self} when self_write=false', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_LIMITED.id}`, + headers: authLimited.headers, + payload: { first_name: 'Nope' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to modify self/) + }) + + it('does not allow user creation to set is_admin', async () => { + const res = await server.inject({ + method: 'POST', + url: '/user', + headers: authFull.headers, + payload: { ...U_CREATED, is_admin: true }, + }) + assert.equal(res.statusCode, 201) + + const [stored] = await User.get({ id: U_CREATED.id }) + assert.equal(stored.is_admin, false) + }) +}) + +describe('authz plugin - group self-ops', () => { + it('403 for PUT /group/{own-group}', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_ROOT.id}`, + headers: authFull.headers, + payload: { name: 'nope' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to edit your own group/) + }) + + it('403 for DELETE /group/{own-group}', async () => { + const res = await server.inject({ + method: 'DELETE', + url: `/group/${G_ROOT.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /Not allowed to delete your own group/) + }) + + it('403 when moving a group beneath itself', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_CHILD.id}`, + headers: authFull.headers, + payload: { parent_gid: G_CHILD.id }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /cannot contain itself/) + }) +}) + +describe('authz plugin - zone record delegation', () => { + it('200 for GET /zone_record/{id} via pseudo-delegation', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone_record/${ZR_OUTSIDE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + }) + + it('403 for GET /zone_record/{id} with limited user', async () => { + const res = await server.inject({ + method: 'GET', + url: `/zone_record/${ZR_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 for an unscoped zone record collection', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone_record', + headers: authFull.headers, + }) + assert.equal(res.statusCode, 403) + }) + + it('403 when moving an in-tree record into a delegated zone without add permission', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE.id}`, + headers: authFull.headers, + payload: { zid: Z_OUTSIDE.id }, + }) + assert.equal(res.statusCode, 403) + + const [record] = await ZoneRecord.get({ id: ZR_INTREE.id }) + assert.equal(record.zid, Z_INTREE.id) + }) + + it('403 when moving a record out of a delegated zone without delete-record permission', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_OUTSIDE.id}`, + headers: authFull.headers, + payload: { zid: Z_INTREE.id }, + }) + assert.equal(res.statusCode, 403) + + const [record] = await ZoneRecord.get({ id: ZR_OUTSIDE.id }) + assert.equal(record.zid, Z_OUTSIDE.id) + }) + + it('moves a record between zones when both sides permit', async () => { + await Delegation.put({ + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + zone_perm_add_records: true, + zone_perm_delete_records: true, + }) + try { + let res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE_OTHER.id}`, + headers: authFull.headers, + payload: { zid: Z_OUTSIDE.id }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await ZoneRecord.get({ id: ZR_INTREE_OTHER.id }))[0].zid, Z_OUTSIDE.id) + + res = await server.inject({ + method: 'PUT', + url: `/zone_record/${ZR_INTREE_OTHER.id}`, + headers: authFull.headers, + payload: { zid: Z_INTREE.id }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await ZoneRecord.get({ id: ZR_INTREE_OTHER.id }))[0].zid, Z_INTREE.id) + } finally { + await Delegation.put({ + gid: G_ROOT.id, + oid: Z_OUTSIDE.id, + type: 'ZONE', + zone_perm_add_records: false, + zone_perm_delete_records: false, + }) + } + }) + + it('does not require create permission when an edit repeats the current zone id', async () => { + const perm = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: perm.id, zonerecord_create: false }) + try { + const res = await server.inject({ + 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('GET without a type reads zone delegations, as the store defaults', async () => { + const res = await server.inject({ + method: 'GET', + url: `/delegation?gid=${G_CHILD.id}&oid=${Z_INTREE.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.delegation.length, 1) + assert.equal(res.result.delegation[0].nt_group_id, G_CHILD.id) + }) + + it('returns 409 without creating a duplicate delegation', async () => { + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: { gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE' }, + }) + assert.equal(res.statusCode, 409) + assert.equal((await Delegation.get({ gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE' })).length, 1) + }) + + it('updates an existing delegation', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/delegation', + headers: authFull.headers, + payload: { gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE', perm_write: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.delegation[0].delegate_write, 1) + }) + + it('returns 404 when updating a missing delegation', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/delegation', + headers: authFull.headers, + payload: { + gid: G_CHILD.id, + oid: ZR_INTREE_OTHER.id, + type: 'ZONERECORD', + perm_write: true, + }, + }) + assert.equal(res.statusCode, 404) + }) + + it('deletes an existing delegation and returns 404 when repeated', async () => { + const url = `/delegation?gid=${G_CHILD.id}&oid=${Z_INTREE.id}&type=ZONE` + let res = await server.inject({ method: 'DELETE', url, headers: authFull.headers }) + assert.equal(res.statusCode, 200) + assert.equal((await Delegation.get({ gid: G_CHILD.id, oid: Z_INTREE.id, type: 'ZONE' })).length, 0) + + res = await server.inject({ method: 'DELETE', url, headers: authFull.headers }) + assert.equal(res.statusCode, 404) + }) + + it('creates one delegation when identical requests race', async () => { + const args = { gid: G_CHILD.id, oid: ZR_INTREE_OTHER.id, type: 'ZONERECORD' } + await Delegation.delete(args) + const results = await Promise.all([1, 2, 3].map(() => Delegation.create(args))) + assert.equal(results.filter((r) => r.created).length, 1) + assert.equal(results.filter((r) => r.duplicate).length, 2) + const rows = await Delegation.get(args) + assert.equal(rows.length, 1) + await Delegation.delete(args) + }) + + it('cannot delegate an object back to your own group', async () => { + const res = await server.inject({ + method: 'POST', + 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 }) + }) + + it('authorizes the group a new group is actually filed under', async () => { + const res = await server.inject({ + method: 'POST', + url: '/group', + headers: authFull.headers, + payload: { + id: G_PLANTED, + name: 'authz-planted', + 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' }, + }) + 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.', + type: 'bind', + ttl: 3600, + address: '192.0.2.11', + export: { 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, + } + + const clearTarget = () => Permission.destroy({ uid: 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) + + await Audit.logZoneRecord(U_FULL, 'modified', ZR_INTREE, Z_INTREE) + await Audit.logZoneRecord(U_FULL, 'modified', ZR_INTREE_OTHER, Z_INTREE) + const log = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${Z_INTREE.id}`, + headers: authLimited.headers, + }) + assert.equal(log.statusCode, 200) + assert.ok(log.result.log.length > 0) + assert.deepEqual(new Set(log.result.log.map((row) => row.zrid)), new Set([ZR_INTREE.id])) + + const write = await server.inject({ + method: 'PUT', + url: `/zone/${Z_INTREE.id}`, + 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) + }) + + it('strips permission fields from a self edit', async () => { + const before = await Permission.get({ uid: U_FULL.id }) + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_FULL.id}`, + headers: authFull.headers, + payload: { first_name: 'Selfish', zone_write: false }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await User.get({ id: U_FULL.id }))[0].first_name, 'Selfish') + + const after = await Permission.get({ uid: U_FULL.id }) + assert.equal(after.zone.write, before.zone.write) + }) +}) + +describe('authz plugin - permission administration', () => { + const U_TARGET = { + id: 4220, + gid: G_CHILD.id, + username: 'authz-admin-target', + email: 'authz-admin-target@example.com', + password: PASSWORD, + first_name: 'Admin', + last_name: 'Target', + inherit_group_permissions: false, + } + const U_GONE = { + id: 4221, + gid: G_CHILD.id, + username: 'authz-gone', + email: 'authz-gone@example.com', + password: PASSWORD, + first_name: 'Gone', + last_name: 'User', + inherit_group_permissions: false, + } + const G_GONE = { id: 4220, parent_gid: G_ROOT.id, name: 'authz-gone' } + + const clear = async () => { + for (const u of [U_TARGET, U_GONE]) { + for (const deleted of [false, true]) { + const p = await Permission.get({ uid: u.id, deleted }) + if (p) await Permission.destroy({ id: p.id }) + } + await User.destroy({ id: u.id }) + } + await Group.destroy({ id: G_GONE.id }) + } + + before(async () => { + await clear() + await Group.create(G_GONE) + for (const u of [U_TARGET, U_GONE]) await User.create(u) + }) + + after(clear) + + it('denies a permission id that does not exist', async () => { + const res = await server.inject({ + method: 'PUT', + url: '/permission/999999', + headers: authFull.headers, + payload: { self_write: true }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /No Access Allowed to that permission/) + }) + + it('caps usable nameservers to those the actor holds', async () => { + const perm = await Permission.get({ gid: G_CHILD.id }) + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { nameserver: { usable: [4200, 4201] } }, + }) + assert.equal(res.statusCode, 200) + const after = await Permission.get({ id: perm.id }) + assert.deepEqual(after.nameserver.usable.map(String), ['4200']) + await Permission.put({ id: perm.id, nameserver: { usable: [] } }) + }) + + it('ignores inherit on a group permission', async () => { + const perm = await Permission.get({ gid: G_CHILD.id }) + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { inherit: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await Permission.get({ id: perm.id })).inherit, false) + }) + + it('does not grant self_write when the actor lacks user write', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + const perm = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: actor.id, user_write: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { self_write: true, zone: { create: true } }, + }) + assert.equal(res.statusCode, 200) + const after = await Permission.get({ id: perm.id }) + assert.equal(after.self_write, false) + assert.equal(after.zone.create, true) + } finally { + await Permission.put({ id: actor.id, user_write: true }) + await Permission.put({ id: perm.id, zone_create: false }) + } + }) + + it('keeps a user explicit when inheriting would grant more than the actor holds', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const group = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: group.id, nameserver: { usable: [4201] } }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { inherit: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal((await Permission.get({ id: perm.id })).inherit, false) + } finally { + await Permission.put({ id: group.id, nameserver: { usable: [] } }) + } + }) + + it('switches a user to inherited permissions the actor can grant', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const res = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { inherit: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.permission.inherit, true) + const group = await Permission.get({ gid: G_CHILD.id }) + assert.equal((await Permission.getEffective(U_TARGET.id)).id, group.id) + await Permission.put({ id: perm.id, inherit: false }) + }) + + it('a limited actor cannot delete another user permissions', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const res = await server.inject({ + method: 'DELETE', + url: `/permission/${perm.id}`, + headers: authLimited.headers, + }) + assert.equal(res.statusCode, 403) + assert.ok(await Permission.get({ id: perm.id })) + }) + + it('deletes another user permissions, which fall back to the group', async () => { + const perm = await Permission.get({ uid: U_TARGET.id }) + const res = await server.inject({ + method: 'DELETE', + url: `/permission/${perm.id}`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.permission.id, perm.id) + assert.equal(await Permission.get({ id: perm.id }), undefined) + const group = await Permission.get({ gid: G_CHILD.id }) + assert.equal((await Permission.getEffective(U_TARGET.id)).id, group.id) + }) + + it('cannot modify permissions for a deleted user', async () => { + const perm = await Permission.get({ uid: U_GONE.id }) + await User.delete({ id: U_GONE.id }) + const put = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { self_write: true }, + }) + assert.equal(put.statusCode, 403) + assert.match(put.result.error_msg, /deleted user/) + + const post = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { name: 'gone', user: { id: U_GONE.id } }, + }) + assert.equal(post.statusCode, 403) + assert.match(post.result.error_msg, /deleted user/) + assert.equal((await Permission.get({ id: perm.id })).self_write, false) + }) + + it('cannot modify permissions for a deleted group, or create in it', async () => { + const perm = await Permission.get({ gid: G_GONE.id }) + await Group.delete({ id: G_GONE.id }) + const put = await server.inject({ + method: 'PUT', + url: `/permission/${perm.id}`, + headers: authFull.headers, + payload: { self_write: true }, + }) + assert.equal(put.statusCode, 403) + assert.match(put.result.error_msg, /deleted group/) + + const post = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { name: 'gone', group: { id: G_GONE.id } }, + }) + assert.equal(post.statusCode, 403) + assert.match(post.result.error_msg, /deleted group/) + + const zone = await server.inject({ + method: 'POST', + url: '/zone', + headers: authFull.headers, + payload: { ...Z_INTREE, id: 4220, gid: G_GONE.id, zone: 'gone.authz.example.com.' }, + }) + assert.equal(zone.statusCode, 403) + assert.match(zone.result.error_msg, /No active target group/) + assert.equal((await Zone.get({ id: 4220 })).length, 0) + }) + + it('refuses a permission with no target', async () => { + const res = await server.inject({ + method: 'POST', + url: '/permission', + headers: authFull.headers, + payload: { name: 'nowhere' }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /No permission target/) + }) +}) + +describe('authz plugin - user permission transitions', () => { + const U_MOVED = { + id: 4222, + gid: G_CHILD.id, + username: 'authz-moved', + email: 'authz-moved@example.com', + password: PASSWORD, + first_name: 'Moved', + last_name: 'User', + inherit_group_permissions: true, + } + + const U_GRANTED = { + id: 4223, + gid: G_CHILD.id, + username: 'authz-granted', + email: 'authz-granted@example.com', + password: PASSWORD, + first_name: 'Granted', + last_name: 'User', + inherit_group_permissions: false, + } + + const clear = async () => { + for (const u of [U_MOVED, U_GRANTED]) { + const p = await Permission.get({ uid: u.id }) + if (p) await Permission.destroy({ id: p.id }) + await User.destroy({ id: u.id }) + } + } + + before(async () => { + await clear() + await User.create(U_MOVED) + }) + + after(clear) + + it('a limited actor cannot change another user permissions', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authLimited.headers, + payload: { zone_write: true }, + }) + assert.equal(res.statusCode, 403) + assert.equal(await Permission.get({ uid: U_MOVED.id }), undefined) + }) + + it('grants only what the actor holds when a field makes a user explicit', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + const group = await Permission.get({ gid: G_CHILD.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + await Permission.put({ id: group.id, zone_delete: true }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { zone_write: true, zone_delete: false }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.user[0].inherit_group_permissions, false) + + const explicit = await Permission.get({ uid: U_MOVED.id }) + assert.equal(explicit.inherit, false) + const effective = await Permission.getEffective(U_MOVED.id) + assert.equal(effective.id, explicit.id) + assert.equal(effective.zone.write, true) + assert.equal(effective.zone.delete, true) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + await Permission.put({ id: group.id, zone_delete: false }) + } + }) + + it('switching a user back to inherited drops the explicit row', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(await Permission.get({ uid: U_MOVED.id }), undefined) + const group = await Permission.get({ gid: G_CHILD.id }) + assert.equal((await Permission.getEffective(U_MOVED.id)).id, group.id) + }) + + it('POST /user stores only the permissions the actor may grant', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'POST', + url: '/user', + headers: authFull.headers, + payload: { ...U_GRANTED, zone_write: true, zone_delete: true }, + }) + assert.equal(res.statusCode, 201) + const perm = await Permission.get({ uid: U_GRANTED.id }) + assert.equal(perm.inherit, false) + assert.equal(perm.zone.write, true) + assert.equal(perm.zone.delete, false) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + } + }) + + it('an explicit switch creates the row once, then edits it', async () => { + const first = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: false }, + }) + assert.equal(first.statusCode, 200) + const explicit = await Permission.get({ uid: U_MOVED.id }) + assert.equal(explicit.inherit, false) + assert.equal(explicit.zone.write, false) + + const second = await server.inject({ + method: 'PUT', + url: `/user/${U_MOVED.id}`, + headers: authFull.headers, + payload: { inherit_group_permissions: false, zone_write: true }, + }) + assert.equal(second.statusCode, 200) + const edited = await Permission.get({ uid: U_MOVED.id }) + assert.equal(edited.id, explicit.id) + assert.equal(edited.zone.write, true) + }) +}) + +describe('authz plugin - group permission mutation', () => { + const G_MADE = { id: 4221, parent_gid: G_ROOT.id, name: 'authz-made' } + + before(async () => { + await Group.destroy({ id: G_MADE.id }) + }) + + after(async () => { + await Group.destroy({ id: G_MADE.id }) + }) + + it('POST /group stores only the permissions the actor may grant', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'POST', + url: '/group', + headers: authFull.headers, + payload: { ...G_MADE, zone_write: true, zone_delete: true, usable_ns: [4200, 4201] }, + }) + assert.equal(res.statusCode, 201) + const perm = await Permission.get({ gid: G_MADE.id }) + assert.equal(perm.zone.write, true) + assert.equal(perm.zone.delete, false) + assert.deepEqual(perm.nameserver.usable.map(String), ['4200']) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + } + }) + + it('PUT /group/{id} changes only the permissions the actor may grant', async () => { + const actor = await Permission.get({ uid: U_FULL.id }) + await Permission.put({ id: actor.id, zone_delete: false }) + try { + const res = await server.inject({ + method: 'PUT', + url: `/group/${G_MADE.id}`, + headers: authFull.headers, + payload: { name: 'authz-remade', zone_delete: true, zonerecord_write: true }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.group[0].name, 'authz-remade') + const perm = await Permission.get({ gid: G_MADE.id }) + assert.equal(perm.zone.write, true) + assert.equal(perm.zone.delete, false) + assert.equal(perm.zonerecord.write, true) + } finally { + await Permission.put({ id: actor.id, zone_delete: true }) + } + }) + + it('scopes the group collection to the caller tree', async () => { + const outside = await server.inject({ + method: 'GET', + url: `/group?parent_gid=${G_ROOT.id}`, + headers: authLimited.headers, + }) + assert.equal(outside.statusCode, 403) + + const own = await server.inject({ method: 'GET', url: '/group', headers: authLimited.headers }) + assert.equal(own.statusCode, 200) + assert.ok(own.result.group.every((g) => g.parent_gid === G_OUTSIDE.id)) + + const tree = await server.inject({ + method: 'GET', + url: `/group?parent_gid=${G_ROOT.id}&include_subgroups=true`, + headers: authFull.headers, + }) + assert.equal(tree.statusCode, 200) + const ids = tree.result.group.map((g) => g.id) + assert.ok(ids.includes(G_CHILD.id)) + assert.ok(ids.includes(G_MADE.id)) + }) +}) + +describe('authz plugin - delegated collection visibility', () => { + const Z_HELD = { + ...Z_INTREE, + id: 4220, + zone: 'held.authz.example.com.', + mailaddr: 'hostmaster.held.authz.example.com.', + } + const held = { gid: G_CHILD.id, oid: Z_HELD.id, type: 'ZONE' } + + const clear = async () => { + await Delegation.delete(held) + await Zone.destroy({ id: Z_HELD.id }) + } + + before(async () => { + await clear() + await Zone.create(Z_HELD) + }) + + after(clear) + + const listHeld = async () => { + const res = await server.inject({ + method: 'GET', + url: `/delegation?gid=${G_CHILD.id}&type=ZONE`, + headers: authFull.headers, + }) + assert.equal(res.statusCode, 200) + return res.result.delegation.filter((d) => d.nt_object_id === Z_HELD.id) + } + + it('lists a delegated zone only while the zone and delegation live', async () => { + assert.deepEqual(await listHeld(), []) + + await Delegation.create({ ...held, perm_write: true, perm_delete: false }) + const [row] = await listHeld() + assert.equal(row.nt_group_id, G_CHILD.id) + assert.equal(row.group_name, G_CHILD.name) + assert.equal(row.nt_zone_id, Z_HELD.id) + assert.equal(row.delegate_write, 1) + assert.equal(row.delegate_delete, 0) + + await Delegation.delete(held) + assert.deepEqual(await listHeld(), []) + + await Delegation.create(held) + await Zone.delete({ id: Z_HELD.id }) + assert.deepEqual(await listHeld(), []) + + const res = await server.inject({ + method: 'POST', + url: '/delegation', + headers: authFull.headers, + payload: held, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /deleted object/) + }) +}) diff --git a/routes/delegation.js b/routes/delegation.js new file mode 100644 index 0000000..4c77c75 --- /dev/null +++ b/routes/delegation.js @@ -0,0 +1,241 @@ +import validate from '@nictool/validate' + +import Authz from '../lib/authz/index.js' +import Delegation from '../lib/delegation/index.js' +import Permission from '../lib/permission/index.js' +import { meta } from '../lib/util.js' + +const DELEGABLE_RESOURCE = { + ZONE: 'zone', + ZONERECORD: 'zonerecord', +} + +const DELEG_PERM_CAP = { + ZONE: { + perm_write: ['zone', 'write'], + perm_delegate: ['zone', 'delegate'], + zone_perm_add_records: ['zonerecord', 'create'], + zone_perm_delete_records: ['zonerecord', 'delete'], + }, + ZONERECORD: { + perm_write: ['zonerecord', 'write'], + perm_delegate: ['zonerecord', 'delegate'], + }, +} + +function capDelegationPerms(payload, perm, sourceDelegation, mode) { + const capMap = DELEG_PERM_CAP[payload.type] + if (!capMap) return + for (const [field, [resource, action]] of Object.entries(capMap)) { + if (payload[field] === undefined) continue + if (perm[resource]?.[action] !== true || (sourceDelegation && sourceDelegation[field] !== 1)) { + if (mode === 'create') payload[field] = false + else delete payload[field] + } + } +} + +function DelegationRoutes(server) { + server.route([ + { + method: 'GET', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'readDelegation' } }, + validate: { + query: validate.delegation.GET_req, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const getArgs = {} + if (request.query.gid !== undefined) getArgs.gid = request.query.gid + if (request.query.oid !== undefined) getArgs.oid = request.query.oid + if (request.query.type !== undefined) getArgs.type = request.query.type + + const delegation = await Delegation.get(getArgs) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `here are your delegations`, + }, + }) + .code(200) + }, + }, + { + method: 'POST', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'delegate', idFrom: 'payload.oid' } }, + validate: { + payload: validate.delegation.POST, + options: { noDefaults: true }, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const { user } = request.auth.credentials + const perm = await Permission.getEffective(user.id) + const sourceDelegation = await sourceDelegationFor(request) + capDelegationPerms(request.payload, perm, sourceDelegation, 'create') + setActor(request) + + const result = await Delegation.create(request.payload) + + if (result.duplicate) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `that delegation already exists`, + }, + }) + .code(409) + } + + const delegation = await Delegation.get({ + gid: request.payload.gid, + oid: request.payload.oid, + type: request.payload.type, + }) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `the delegation was created`, + }, + }) + .code(201) + }, + }, + { + method: 'PUT', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'editDelegation', idFrom: 'payload.oid' } }, + validate: { + payload: validate.delegation.PUT, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const { user } = request.auth.credentials + const perm = await Permission.getEffective(user.id) + capDelegationPerms(request.payload, perm, null, 'edit') + setActor(request) + + const result = await Delegation.put(request.payload) + + if (result === null) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I couldn't find that delegation`, + }, + }) + .code(404) + } + + const delegation = await Delegation.get({ + gid: request.payload.gid, + oid: request.payload.oid, + type: request.payload.type, + }) + + return h + .response({ + delegation, + meta: { + api: meta.api, + msg: `the delegation was updated`, + }, + }) + .code(200) + }, + }, + { + method: 'DELETE', + path: '/delegation', + options: { + app: { permission: { resource: 'zone', action: 'deleteDelegation', idFrom: 'query.oid' } }, + validate: { + query: validate.delegation.DELETE, + }, + response: { + schema: validate.delegation.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const args = { + gid: request.query.gid, + oid: request.query.oid, + type: request.query.type, + delegated_by_id: request.auth.credentials.user.id, + delegated_by_name: request.auth.credentials.user.username, + } + + const result = await Delegation.delete(args) + + if (result === null) { + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I couldn't find that delegation`, + }, + }) + .code(404) + } + + return h + .response({ + delegation: [], + meta: { + api: meta.api, + msg: `I deleted that delegation`, + }, + }) + .code(200) + }, + }, + ]) +} + +function setActor(request) { + request.payload.delegated_by_id = request.auth.credentials.user.id + request.payload.delegated_by_name = request.auth.credentials.user.username +} + +async function sourceDelegationFor(request) { + const resource = DELEGABLE_RESOURCE[request.payload.type] + if (!resource) return null + const gid = await Authz.getObjectGroupId(resource, request.payload.oid) + if (gid !== null && (await Authz.isInGroupTree(request.auth.credentials.group.id, gid))) { + return null + } + return Authz.getDelegateAccess(request.auth.credentials.group.id, request.payload.oid, resource) +} + +export default DelegationRoutes + +export { Delegation, DelegationRoutes } diff --git a/routes/group.js b/routes/group.js index d835f40..dcb49e0 100644 --- a/routes/group.js +++ b/routes/group.js @@ -3,14 +3,64 @@ 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/index.js' +import Permission from '../lib/permission/index.js' import { meta } from '../lib/util.js' +const PERM_FIELDS = new Set([ + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', + 'self_write', + 'usable_ns', +]) + +const GROUP_POST_FIELDS = new Set(['id', 'name', 'parent_gid', 'deleted', 'usable_ns']) + +const GROUP_PUT_FIELDS = new Set(['name', 'parent_gid', 'deleted', 'usable_ns']) + +function extractPermFields(payload) { + const permFields = {} + for (const key of Object.keys(payload)) { + if (PERM_FIELDS.has(key)) { + permFields[key] = payload[key] + delete payload[key] + } + } + return permFields +} + +function pickFields(payload, fields) { + return Object.fromEntries(Object.entries(payload).filter(([key]) => fields.has(key))) +} + function GroupRoutes(server) { server.route([ { method: 'GET', path: '/group', options: { + app: { + permission: { + resource: 'group', + action: 'read', + list: { resource: 'group', idFrom: 'query.parent_gid', defaultToGroup: true }, + }, + }, validate: { query: validate.group.GET_list_req, }, @@ -24,7 +74,7 @@ function GroupRoutes(server) { deleted: request.query.deleted === true ? 1 : 0, include_subgroups: request.query.include_subgroups === true, } - if (request.query.parent_gid !== undefined) getArgs.parent_gid = request.query.parent_gid + getArgs.parent_gid = request.query.parent_gid ?? request.auth.credentials.group.id if (request.query.name !== undefined) getArgs.name = request.query.name const groups = await Group.get(getArgs) @@ -36,20 +86,25 @@ function GroupRoutes(server) { method: 'GET', path: '/group/{id}', options: { + app: { permission: { resource: 'group', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.group.GET_req, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { - const groups = await Group.get({ - deleted: request.query.deleted ?? 0, + const getArgs = { id: parseInt(request.params.id, 10), include_subgroups: request.query.include_subgroups === true, - }) + } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } + const groups = await Group.get(getArgs) // Return an array like the other object types (zone/nameserver/user/ // zone_record) rather than a bare object, for a consistent API contract. @@ -68,16 +123,28 @@ function GroupRoutes(server) { method: 'POST', path: '/group', options: { + app: { permission: { resource: 'group', action: 'create' } }, validate: { payload: validate.group.POST, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { - const gid = await Group.create(request.payload) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + const gid = await Group.create(pickFields(request.payload, GROUP_POST_FIELDS)) + + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ gid }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } const groups = await Group.get({ id: gid }) @@ -96,17 +163,36 @@ function GroupRoutes(server) { method: 'PUT', path: '/group/{id}', options: { + app: { + permission: { + resource: 'group', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.parent_gid', + }, + }, validate: { payload: validate.group.PUT, }, response: { schema: validate.group.GET_res, + failAction: 'log', }, tags: ['api'], }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) - await Group.put({ ...request.payload, id }) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + const existingPerm = await Permission.get({ gid: id }) + request.payload = Authz.capPermissions(userPerm, request.payload, existingPerm) + + const permFields = extractPermFields(request.payload) + if (Object.keys(permFields).length > 0) { + if (existingPerm) await Permission.put({ id: existingPerm.id, ...permFields }) + } + + await Group.put({ ...pickFields(request.payload, GROUP_PUT_FIELDS), id }) const groups = await Group.get({ id }) @@ -125,6 +211,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..0fba06d 100644 --- a/routes/group.test.js +++ b/routes/group.test.js @@ -3,9 +3,11 @@ import { describe, it, before, after } from 'node:test' import { init } from './index.js' import Group from '../lib/group/index.js' +import Permission from '../lib/permission/index.js' import User from '../lib/user/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } let server @@ -15,6 +17,9 @@ before(async () => { server = await init() await Group.create(groupCase) await User.create(userCase) + await grantGroupPermissions(groupCase.id) + const permissions = await Permission.getEffective(userCase.id) + assert.equal(permissions.group.create, true) }) after(async () => { @@ -53,6 +58,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({ @@ -61,7 +69,7 @@ describe('group routes', () => { headers: auth.headers, payload: testCase, }) - assert.equal(res.statusCode, 201) + assert.equal(res.statusCode, 201, JSON.stringify(res.result)) }) it(`GET /group/${case2Id}`, async () => { @@ -74,6 +82,16 @@ describe('group routes', () => { assert.equal(res.result.group[0].id, case2Id) }) + it(`PUT /group/${case2Id} rejects unknown permission controls`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${case2Id}`, + headers: auth.headers, + payload: { definitely_not_a_permission: true }, + }) + assert.equal(res.statusCode, 400) + }) + it(`DELETE /group/${case2Id}`, async () => { const res = await server.inject({ method: 'DELETE', diff --git a/routes/index.js b/routes/index.js index 598f092..0894662 100644 --- a/routes/index.js +++ b/routes/index.js @@ -24,6 +24,9 @@ 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 { LogRoutes } from './log.js' +import authzPlugin from '../lib/authz-plugin.js' let server @@ -90,7 +93,7 @@ async function setup() { sub: false, nbf: true, exp: true, - maxAgeSec: 14400, // 4 hours + maxAgeSec: 28800, // the token's ttl; idle expiry is the session's job timeSkewSec: 15, }, httpAuthScheme: 'Bearer', @@ -105,6 +108,8 @@ async function setup() { server.auth.default('nt_jwt_strategy') + await server.register(authzPlugin) + server.route({ method: 'GET', path: '/', @@ -120,6 +125,8 @@ async function setup() { NameserverRoutes(server) ZoneRoutes(server) ZoneRecordRoutes(server) + DelegationRoutes(server) + LogRoutes(server) server.route({ method: '*', @@ -139,7 +146,7 @@ async function setup() { } }) - server.events.on('stop', async () => { + server.ext('onPostStop', async () => { await User.disconnect() await Session.disconnect() }) diff --git a/routes/lifecycle.test.js b/routes/lifecycle.test.js new file mode 100644 index 0000000..5ffb152 --- /dev/null +++ b/routes/lifecycle.test.js @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import User from '../lib/user/index.js' +import { init } from './index.js' + +test('server stop waits for store disconnection', async (t) => { + const server = await init() + let enterDisconnect + let releaseDisconnect + const entered = new Promise((resolve) => { + enterDisconnect = resolve + }) + const released = new Promise((resolve) => { + releaseDisconnect = resolve + }) + + t.mock.method(User, 'disconnect', async () => { + enterDisconnect() + await released + }) + + let stopped = false + const stopping = server.stop().then(() => { + stopped = true + }) + await entered + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(stopped, false) + + releaseDisconnect() + await stopping + assert.equal(stopped, true) +}) diff --git a/routes/log.js b/routes/log.js new file mode 100644 index 0000000..36b81c3 --- /dev/null +++ b/routes/log.js @@ -0,0 +1,87 @@ +import validate from '@nictool/validate' + +import Audit from '../lib/audit/index.js' +import Group from '../lib/group/index.js' +import Authz from '../lib/authz/index.js' +import { meta } from '../lib/util.js' + +function LogRoutes(server) { + server.route([ + { + method: 'GET', + path: '/log/global', + options: groupLogOptions(validate.log.GET_global_req), + handler: async (request, h) => { + const gids = await groupScope(request) + return logResponse(h, await Audit.listGlobal({ ...request.query, gids })) + }, + }, + { + method: 'GET', + path: '/log/zone', + options: groupLogOptions(validate.log.GET_zone_req), + handler: async (request, h) => { + const gids = await groupScope(request) + return logResponse(h, await Audit.listZones({ ...request.query, gids })) + }, + }, + { + method: 'GET', + path: '/log/zone_record', + options: { + app: { permission: { resource: 'zone', action: 'read', idFrom: 'query.zid' } }, + validate: { query: validate.log.GET_zone_record_req }, + response: { schema: validate.log.GET_res }, + tags: ['api'], + }, + handler: async (request, h) => { + // a record-only delegation reads the zone but not every record in it + const ids = await Authz.getZoneRecordReadScope(request.auth.credentials.group.id, request.query.zid) + const args = ids === null ? request.query : { ...request.query, ids } + return logResponse(h, await Audit.listZoneRecords(args)) + }, + }, + ]) +} + +function groupLogOptions(querySchema) { + return { + app: { + permission: { + resource: 'log', + action: 'read', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, + validate: { query: querySchema }, + response: { schema: validate.log.GET_res }, + tags: ['api'], + } +} + +async function groupScope(request) { + const gid = request.query.gid ?? request.auth.credentials.group.id + return request.query.include_subgroups === true ? Group.subgroupGids(gid) : [gid] +} + +function logResponse(h, result) { + return h + .response({ + log: result.rows, + meta: { + api: meta.api, + msg: 'audit entries', + pagination: { + total: result.total, + filtered: result.filtered, + limit: result.limit, + offset: result.offset, + }, + }, + }) + .code(200) +} + +export default LogRoutes + +export { LogRoutes } diff --git a/routes/nameserver.js b/routes/nameserver.js index 3fb4ea1..9b85ca0 100644 --- a/routes/nameserver.js +++ b/routes/nameserver.js @@ -9,6 +9,14 @@ function NameserverRoutes(server) { method: 'GET', path: '/nameserver/{id?}', options: { + app: { + permission: { + resource: 'nameserver', + action: 'read', + idFrom: 'params.id', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.nameserver.GET_req, }, @@ -18,11 +26,18 @@ function NameserverRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const getArgs = { - deleted: request.query.deleted === true ? 1 : 0, + const getArgs = {} + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) - if (request.query.gid) getArgs.gid = parseInt(request.query.gid, 10) + // authz has already scoped a single-object fetch, which may resolve + // through a usable_ns grant on a nameserver outside the caller's group + if (request.query.gid !== undefined) { + getArgs.gid = parseInt(request.query.gid, 10) + } else if (!request.params.id) { + getArgs.gid = request.auth.credentials.group.id + } const nameservers = await Nameserver.get(getArgs) @@ -41,6 +56,7 @@ function NameserverRoutes(server) { method: 'POST', path: '/nameserver', options: { + app: { permission: { resource: 'nameserver', action: 'create' } }, validate: { payload: validate.nameserver.POST, }, @@ -69,6 +85,14 @@ function NameserverRoutes(server) { method: 'PUT', path: '/nameserver/{id}', options: { + app: { + permission: { + resource: 'nameserver', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.gid', + }, + }, validate: { payload: validate.nameserver.PUT, }, @@ -98,6 +122,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..6401cdf 100644 --- a/routes/nameserver.test.js +++ b/routes/nameserver.test.js @@ -7,22 +7,27 @@ 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' } let server let case2Id = 4094 +const moveGroup = { id: 4090, parent_gid: groupCase.id, name: 'ns-move.route.example.com' } before(async () => { await Nameserver.destroy({ id: case2Id }) await Group.create(groupCase) + await Group.create(moveGroup) await User.create(userCase) + await grantGroupPermissions(groupCase.id) await Nameserver.create(nsCase) server = await init() }) after(async () => { await Nameserver.destroy({ id: case2Id }) + await Group.destroy({ id: moveGroup.id }) await server.stop() }) @@ -53,6 +58,26 @@ describe('nameserver routes', () => { assert.equal(res.result.nameserver[0].name, nsCase.name) }) + it(`PUT /nameserver/${nsCase.id} moves it to another group`, async () => { + const moved = await server.inject({ + method: 'PUT', + url: `/nameserver/${nsCase.id}`, + headers: auth.headers, + payload: { gid: moveGroup.id }, + }) + assert.equal(moved.statusCode, 200) + assert.equal(moved.result.nameserver[0].gid, moveGroup.id) + + const restored = await server.inject({ + method: 'PUT', + url: `/nameserver/${nsCase.id}`, + headers: auth.headers, + payload: { gid: groupCase.id }, + }) + assert.equal(restored.statusCode, 200) + assert.equal(restored.result.nameserver[0].gid, groupCase.id) + }) + it(`POST /nameserver (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique diff --git a/routes/permission.js b/routes/permission.js index b06f537..ffd1885 100644 --- a/routes/permission.js +++ b/routes/permission.js @@ -1,5 +1,6 @@ import validate from '@nictool/validate' +import Authz from '../lib/authz/index.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,18 @@ function PermissionRoutes(server) { tags: ['api'], }, handler: async (request, h) => { + const userPerm = await Permission.getEffective(request.auth.credentials.user.id) + const uid = request.payload.user?.id + if (uid !== undefined && request.payload.group?.id == null) { + const gid = await Authz.getObjectGroupId('user', uid) + request.payload.group = { ...request.payload.group, id: gid } + } + const currentPerm = uid === undefined ? null : await Permission.getEffective(uid) + request.payload = Authz.capPermissions(userPerm, request.payload, currentPerm) + if (uid !== undefined && request.payload.inherit !== true) { + request.payload = Authz.preserveUnmanagedPermissions(userPerm, request.payload, currentPerm) + } + delete request.payload.id const pid = await Permission.create(request.payload) const permission = await Permission.get({ id: pid }) @@ -64,10 +79,61 @@ function PermissionRoutes(server) { .code(201) }, }, + { + method: 'PUT', + path: '/permission/{id}', + options: { + app: { permission: { resource: 'permission', action: 'write', idFrom: 'params.id' } }, + validate: { + payload: validate.permission.POST, + }, + response: { + schema: validate.permission.GET_res, + }, + tags: ['api'], + }, + handler: async (request, h) => { + const id = parseInt(request.params.id, 10) + const existing = await Permission.get({ id }) + if (!existing) { + return h.response({ meta: { api: meta.api, msg: `permission not found` } }).code(404) + } + + const userPerm = await Permission.getEffective(request.auth.credentials.user.id) + const payload = Authz.capPermissions(userPerm, request.payload, existing) + if (payload.inherit !== undefined) { + const uid = existing.user?.id + if (uid === undefined || uid === null) { + delete payload.inherit + } else { + const gid = await Authz.getObjectGroupId('user', uid) + const groupPerm = gid === null ? null : await Permission.get({ gid }) + const before = existing.inherit === false ? existing : groupPerm + const after = payload.inherit ? groupPerm : existing + if (!Authz.canTransitionPermissions(userPerm, before, after)) { + delete payload.inherit + } + } + } + delete payload.id + delete payload.user + delete payload.group + await Permission.put({ ...payload, id }) + const permission = await Permission.get({ id }) + + return h + .response({ + permission, + meta: { api: meta.api, msg: `permission updated` }, + }) + .code(200) + }, + }, { method: 'DELETE', path: '/permission/{id}', options: { + app: { permission: { resource: 'permission', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.permission.DELETE, failAction: 'log', diff --git a/routes/permission.test.js b/routes/permission.test.js index 18089f4..73077fa 100644 --- a/routes/permission.test.js +++ b/routes/permission.test.js @@ -52,14 +52,8 @@ describe('permission routes', () => { assert.equal(res.result.permission.nameserver.create, false) }) - it(`POST /permission (${case2Id})`, async () => { + it('POST /permission cannot create your own permissions', async () => { const testCase = JSON.parse(JSON.stringify(permCase)) - testCase.id = case2Id // make it unique - testCase.user.id = case2Id - testCase.group.id = case2Id - testCase.name = `Route Test Permission 2` - delete testCase.deleted - // console.log(testCase) const res = await server.inject({ method: 'POST', @@ -67,64 +61,29 @@ describe('permission routes', () => { headers: auth.headers, payload: testCase, }) - // console.log(res.result) - assert.equal(res.statusCode, 201) - assert.equal(res.result.permission.zone.create, true) - assert.equal(res.result.permission.nameserver.create, false) - }) - - it(`GET /permission/${case2Id}`, async () => { - const res = await server.inject({ - method: 'GET', - url: `/permission/${case2Id}`, - headers: auth.headers, - }) - // console.log(res.result) - assert.equal(res.statusCode, 200) - assert.equal(res.result.permission.zone.create, true) - assert.equal(res.result.permission.nameserver.create, false) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own permissions/) }) - it(`DELETE /permission/${case2Id}`, async () => { + it(`PUT /permission/${userCase.id} cannot change your own permissions`, async () => { const res = await server.inject({ - method: 'DELETE', - url: `/permission/${case2Id}`, + method: 'PUT', + url: `/permission/${userCase.id}`, headers: auth.headers, + payload: permCase, }) - // console.log(res.result) - assert.equal(res.statusCode, 200) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own permissions/) }) - it(`DELETE /permission/${case2Id}`, async () => { + it(`DELETE /permission/${userCase.id} cannot delete your own permissions`, async () => { const res = await server.inject({ method: 'DELETE', - url: `/permission/${case2Id}`, - headers: auth.headers, - }) - // console.log(res.result) - assert.equal(res.statusCode, 404) - }) - - it(`GET /permission/${case2Id}`, async () => { - const res = await server.inject({ - method: 'GET', - url: `/permission/${case2Id}`, - headers: auth.headers, - }) - // console.log(res.result) - // assert.equal(res.statusCode, 200) - assert.equal(res.result.permission, undefined) - }) - - it(`GET /permission/${case2Id} (deleted)`, async () => { - const res = await server.inject({ - method: 'GET', - url: `/permission/${case2Id}?deleted=true`, + url: `/permission/${userCase.id}`, headers: auth.headers, }) - // console.log(res.result) - assert.equal(res.statusCode, 200) - assert.ok(res.result.permission) + assert.equal(res.statusCode, 403) + assert.match(res.result.error_msg, /own permissions/) }) it('DELETE /session', async () => { diff --git a/routes/session.js b/routes/session.js index e49ee4e..7b86c7d 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' @@ -24,13 +25,25 @@ function SessionRoutes(server) { handler: async (request, h) => { const { user, group, session } = h.request.auth.credentials - Session.put({ id: session.id, last_access: true }) + Session.put({ id: session.id, last_access: true }).catch((err) => { + console.error(`session ${session.id} activity: ${err.message}`) + }) + + const perm = await Permission.getEffective(user.id) + const groupPerm = await Permission.getGroup({ + 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`, @@ -83,11 +96,21 @@ function SessionRoutes(server) { }, ) + const perm = await Permission.getEffective(account.user.id) + const groupPerm = await Permission.getGroup({ + uid: account.user.id, + deleted: false, + }) + if (perm && groupPerm) { + perm.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } + return h .response({ user: account.user, group: account.group, session: { id: sessId, token: token }, + permissions: perm ?? {}, meta: { api: meta.api, msg: `you are logged in`, diff --git a/routes/session.test.js b/routes/session.test.js index a49f1ee..ab56aec 100644 --- a/routes/session.test.js +++ b/routes/session.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { describe, it, before, after } from 'node:test' +import Jwt from '@hapi/jwt' + import { init } from './index.js' import userCase from './test/user.json' with { type: 'json' } import groupCase from './test/group.json' with { type: 'json' } @@ -9,6 +11,7 @@ import permCase from './test/permission.json' with { type: 'json' } import User from '../lib/user/index.js' import Group from '../lib/group/index.js' import Permission from '../lib/permission/index.js' +import Config from '../lib/config.js' let server @@ -58,6 +61,23 @@ describe('session routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) + it('honours a token issued five hours ago while its session is active', async () => { + const { user, group, session } = Jwt.token.decode(auth.headers.Authorization.replace('Bearer ', '')) + .decoded.payload.nt + const { jwt } = await Config.get('http') + const token = Jwt.token.generate( + { aud: 'urn:audience:test', iss: 'urn:issuer:test', nt: { user, group, session } }, + { key: jwt.key, algorithm: 'HS512' }, + { ttlSec: 28800, now: Date.now() - 5 * 3600 * 1000 }, + ) + const res = await server.inject({ + method: 'GET', + url: '/session', + headers: { Authorization: `Bearer ${token}` }, + }) + assert.equal(res.statusCode, 200) + }) + after(async () => { const res = await server.inject({ method: 'DELETE', @@ -66,9 +86,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] @@ -84,4 +111,15 @@ describe('session routes', () => { }) } }) + + it('rejects malformed qualified usernames', async () => { + for (const username of ['a'.repeat(51), `@${groupCase.name}`, 'valid@x', 'valid@-invalid']) { + const res = await server.inject({ + method: 'POST', + url: '/session', + payload: { username, password: userCase.password }, + }) + assert.equal(res.statusCode, 400, username) + } + }) }) diff --git a/routes/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..69c3f16 100644 --- a/routes/user.js +++ b/routes/user.js @@ -2,14 +2,92 @@ import validate from '@nictool/validate' import User from '../lib/user/index.js' import Credentials from '../lib/user/credentials.js' +import Authz from '../lib/authz/index.js' +import Permission from '../lib/permission/index.js' +import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' +const PERM_FIELDS = new Set([ + 'group_write', + 'group_create', + 'group_delete', + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + 'user_write', + 'user_create', + 'user_delete', + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', + 'self_write', + 'usable_ns', +]) + +const USER_POST_FIELDS = new Set([ + 'id', + 'gid', + 'first_name', + 'last_name', + 'username', + 'email', + 'password', + 'inherit_group_permissions', +]) + +const USER_PUT_FIELDS = new Set([ + 'gid', + 'first_name', + 'last_name', + 'username', + 'email', + 'password', + 'deleted', + 'inherit_group_permissions', +]) + +function extractPermFields(payload) { + const permFields = {} + for (const key of Object.keys(payload)) { + if (PERM_FIELDS.has(key)) { + permFields[key] = payload[key] + delete payload[key] + } + } + return permFields +} + +function pickFields(payload, fields) { + return Object.fromEntries(Object.entries(payload).filter(([key]) => fields.has(key))) +} + +function prepareUserResponse(user) { + const gid = parseInt(user.gid, 10) + delete user.gid + if (user.permissions?.group && user.permissions.group.id == null) { + delete user.permissions.group.id + } + return gid +} + function UserRoutes(server) { server.route([ { method: 'GET', path: '/user', options: { + app: { + permission: { + resource: 'user', + action: 'read', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.user.GET_req, }, @@ -25,10 +103,33 @@ function UserRoutes(server) { gid: parseInt(gid, 10), deleted: request.query.deleted ?? false, include_subgroups: request.query.include_subgroups === true, + limit: await pageLimit(request.query.limit), } - const users = await User.get(getArgs) - for (const u of users) delete u.gid + if (request.query.search) getArgs.search = request.query.search + if (request.query.exact_match === true) getArgs.exact_match = true + if (Number.isInteger(request.query.offset)) getArgs.offset = request.query.offset + if (request.query.sort_by) getArgs.sort_by = request.query.sort_by + if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir + + const countArgs = { + gid: getArgs.gid, + deleted: getArgs.deleted, + include_subgroups: getArgs.include_subgroups, + ...(getArgs.search ? { search: getArgs.search } : {}), + ...(getArgs.exact_match ? { exact_match: true } : {}), + } + const totalArgs = { + gid: getArgs.gid, + deleted: getArgs.deleted, + include_subgroups: getArgs.include_subgroups, + } + const [users, filtered, total] = await Promise.all([ + User.get(getArgs), + User.count(countArgs), + User.count(totalArgs), + ]) + for (const u of users) prepareUserResponse(u) return h .response({ @@ -36,6 +137,12 @@ function UserRoutes(server) { meta: { api: meta.api, msg: `users in group`, + pagination: { + total, + filtered, + limit: getArgs.limit, + offset: getArgs.offset ?? 0, + }, }, }) .code(200) @@ -45,6 +152,7 @@ function UserRoutes(server) { method: 'GET', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'read', idFrom: 'params.id' } }, validate: { query: validate.user.GET_req, }, @@ -54,10 +162,11 @@ function UserRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const users = await User.get({ - deleted: request.query.deleted ?? 0, - id: parseInt(request.params.id, 10), - }) + const getArgs = { id: parseInt(request.params.id, 10) } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true + } + const users = await User.get(getArgs) if (users.length !== 1) { return h @@ -70,8 +179,14 @@ function UserRoutes(server) { .code(204) } - const gid = parseInt(users[0].gid, 10) - delete users[0].gid + const gid = prepareUserResponse(users[0]) + const groupPerm = await Permission.getGroup({ + uid: getArgs.id, + deleted: false, + }) + if (users[0].permissions && groupPerm) { + users[0].permissions.nameserver.usable = groupPerm.nameserver?.usable ?? [] + } return h .response({ @@ -89,6 +204,7 @@ function UserRoutes(server) { method: 'POST', path: '/user', options: { + app: { permission: { resource: 'user', action: 'create' } }, validate: { payload: validate.user.POST, }, @@ -98,14 +214,20 @@ function UserRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const uid = await User.create(request.payload) - if (!uid) { - console.log(`POST /user oops`) // TODO + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + request.payload = Authz.capPermissions(userPerm, request.payload) + + const permFields = extractPermFields(request.payload) + const uid = await User.create(pickFields(request.payload, USER_POST_FIELDS)) + + if (Object.keys(permFields).length > 0) { + const perm = await Permission.get({ uid }) + if (perm) await Permission.put({ id: perm.id, ...permFields }) } const users = await User.get({ id: uid }) - const group = { id: users[0].gid } - delete users[0].gid + const group = { id: prepareUserResponse(users[0]) } return h .response({ @@ -123,6 +245,14 @@ function UserRoutes(server) { method: 'PUT', path: '/user/{id}', options: { + app: { + permission: { + resource: 'user', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.gid', + }, + }, validate: { payload: validate.user.PUT, }, @@ -133,6 +263,42 @@ function UserRoutes(server) { }, handler: async (request, h) => { const id = parseInt(request.params.id, 10) + const { user } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + const existingPerm = await Permission.get({ uid: id }) + const gid = await Authz.getObjectGroupId('user', id) + const groupPerm = gid === null ? null : await Permission.get({ gid }) + const effectivePerm = existingPerm?.inherit === false ? existingPerm : groupPerm + request.payload = Authz.capPermissions(userPerm, request.payload, existingPerm) + + const hasPermFields = Object.keys(request.payload).some((field) => PERM_FIELDS.has(field)) + if (request.payload.inherit_group_permissions === false || (!existingPerm && hasPermFields)) { + request.payload = Authz.preserveUnmanagedPermissions(userPerm, request.payload, effectivePerm) + } + + const permFields = extractPermFields(request.payload) + + // self_write grants profile edits only; own permissions change via /permission, + // which denies self-targeted writes + if (id === user.id) { + for (const field of Object.keys(permFields)) delete permFields[field] + } + + request.payload = pickFields(request.payload, USER_PUT_FIELDS) + + // switching yourself back to inherited permissions adopts the group's, + // which capPermissions can't cap because it isn't a permission field + if (id === user.id) delete request.payload.inherit_group_permissions + + if (request.payload.inherit_group_permissions !== undefined) { + const after = request.payload.inherit_group_permissions ? groupPerm : (existingPerm ?? {}) + if (!Authz.canTransitionPermissions(userPerm, effectivePerm, after)) { + delete request.payload.inherit_group_permissions + } else if (request.payload.inherit_group_permissions === true) { + for (const field of Object.keys(permFields)) delete permFields[field] + } + } + const args = { ...request.payload, id } // no salt passed: a password change always gets a fresh one @@ -142,11 +308,26 @@ function UserRoutes(server) { await User.put(args) + if (Object.keys(permFields).length > 0) { + let perm = await Permission.get({ uid: id }) + if (!perm) { + const [userData] = await User.get({ id }) + const permId = await Permission.create({ + uid: id, + gid: userData.gid, + inherit: false, + name: `User ${userData.username} perms`, + }) + perm = await Permission.get({ id: permId }) + } + if (perm) await Permission.put({ id: perm.id, ...permFields }) + } + const users = await User.get({ id }) if (!users.length) { return h.response({ meta: { api: meta.api, msg: `user not found` } }).code(404) } - delete users[0].gid + prepareUserResponse(users[0]) return h .response({ @@ -160,6 +341,7 @@ function UserRoutes(server) { method: 'DELETE', path: '/user/{id}', options: { + app: { permission: { resource: 'user', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.user.DELETE, }, @@ -184,7 +366,7 @@ function UserRoutes(server) { await User.delete({ id: users[0].id }) - delete users[0].gid + prepareUserResponse(users[0]) return h .response({ diff --git a/routes/user.test.js b/routes/user.test.js index da88079..d058a56 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -4,23 +4,32 @@ import { describe, it, before, after } from 'node:test' import { init } from './index.js' import User from '../lib/user/index.js' import Group from '../lib/group/index.js' +import Permission from '../lib/permission/index.js' +import Session from '../lib/session/index.js' +import Authz from '../lib/authz/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, - auth = { headers: {} } + auth = { headers: {} }, + sessionId before(async () => { server = await init() await Group.create(groupCase) + await Group.create(moveGroup) await User.create(userCase) + await grantGroupPermissions(groupCase.id) }) const userId2 = 4094 +const moveGroup = { id: 4090, parent_gid: groupCase.id, name: 'user-move.route.example.com' } after(async () => { - User.destroy({ id: userId2 }) + await User.destroy({ id: userId2 }) + await Group.destroy({ id: moveGroup.id }) await server.stop() }) @@ -35,9 +44,46 @@ describe('user routes', () => { }, }) assert.ok(res.result.user.id) + sessionId = res.result.session.id auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) + it('authenticated requests refresh session activity', async (t) => { + const sessionBefore = await Session.get({ id: sessionId }) + const now = sessionBefore.last_access + 7200 + t.mock.method(Date, 'now', () => now * 1000) + + const res = await server.inject({ + method: 'GET', + url: '/user', + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + + // the plugin's activity touch is fire-and-forget; give it a beat + let lastAccess = sessionBefore.last_access + for (let i = 0; i < 20 && lastAccess <= sessionBefore.last_access; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + const session = await Session.get({ id: sessionId }) + lastAccess = session?.last_access ?? sessionBefore.last_access + } + assert.equal(lastAccess, now) + }) + + it('a failed activity touch does not fail the request', async (t) => { + t.mock.method(Session, 'put', async () => { + throw new Error('store unavailable') + }) + + const res = await server.inject({ + method: 'GET', + url: '/user', + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + await new Promise((resolve) => setImmediate(resolve)) + }) + it('GET /user', async () => { const res = await server.inject({ method: 'GET', @@ -61,6 +107,7 @@ describe('user routes', () => { const testCase = JSON.parse(JSON.stringify(userCase)) testCase.id = userId2 // make it unique testCase.username = `${testCase.username}2` + testCase.inherit_group_permissions = false delete testCase.deleted const res = await server.inject({ @@ -72,6 +119,75 @@ describe('user routes', () => { assert.equal(res.statusCode, 201) }) + it('GET /user searches and paginates', async () => { + const res = await server.inject({ + method: 'GET', + url: `/user?gid=${userCase.gid}&search=${userCase.username}2&exact_match=true&limit=10&offset=0&sort_by=username&sort_dir=desc`, + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual( + res.result.user.map((u) => u.username), + [`${userCase.username}2`], + ) + assert.equal(res.result.meta.pagination.filtered, 1) + assert.equal(res.result.meta.pagination.limit, 10) + assert.equal(res.result.meta.pagination.offset, 0) + }) + + it(`PUT /user/${userId2} moves it to another group`, async () => { + const moved = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { gid: moveGroup.id }, + }) + assert.equal(moved.statusCode, 200) + assert.equal((await User.get({ id: userId2 }))[0].gid, moveGroup.id) + const movedPermission = await Permission.get({ uid: userId2 }) + assert.equal(movedPermission.group.id, moveGroup.id) + assert.equal((await Authz.permissionRecord(movedPermission.id)).target_gid, moveGroup.id) + + const listed = await server.inject({ + method: 'GET', + url: `/user?gid=${groupCase.id}&include_subgroups=true`, + headers: auth.headers, + }) + assert.equal(listed.statusCode, 200) + assert.ok(listed.result.user.some((u) => u.id === userId2)) + assert.equal(listed.result.meta.pagination.total, listed.result.user.length) + + const sorted = await server.inject({ + method: 'GET', + url: `/user?gid=${groupCase.id}&include_subgroups=true&sort_by=group_name&sort_dir=asc`, + headers: auth.headers, + }) + assert.equal(sorted.statusCode, 200) + assert.equal(sorted.result.user.find((u) => u.id === userId2).group_name, moveGroup.name) + + const restored = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { gid: groupCase.id }, + }) + assert.equal(restored.statusCode, 200) + assert.equal((await User.get({ id: userId2 }))[0].gid, groupCase.id) + const restoredPermission = await Permission.get({ uid: userId2 }) + assert.equal(restoredPermission.group.id, groupCase.id) + assert.equal((await Authz.permissionRecord(restoredPermission.id)).target_gid, groupCase.id) + }) + + it(`PUT /user/${userId2} rejects unknown permission controls`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { definitely_not_a_permission: true }, + }) + assert.equal(res.statusCode, 400) + }) + it(`GET /user/${userId2}`, async () => { const res = await server.inject({ method: 'GET', diff --git a/routes/zone.js b/routes/zone.js index a3462d0..bf61841 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -1,16 +1,74 @@ import validate from '@nictool/validate' import Zone from '../lib/zone/index.js' +import { ZoneNameConflictError } from '../lib/zone/store/base.js' import Group from '../lib/group/index.js' -import Mysql from '../lib/mysql.js' +import Authz from '../lib/authz/index.js' +import Permission from '../lib/permission/index.js' +import Audit from '../lib/audit/index.js' +import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' +const ZONE_PUT_FIELDS = new Set([ + 'nameservers', + 'gid', + 'description', + 'mailaddr', + 'serial', + 'ttl', + 'refresh', + 'retry', + 'expire', + 'minimum', + 'deleted', +]) + +// a zone may only be assigned nameservers the caller can use: those owned +// by a group in the caller's tree, or granted through usable_ns (the v2 rule) +async function unusableNameservers(request) { + const requested = request.payload.nameservers + if (!Array.isArray(requested)) return [] + const ids = [...new Set(requested.map(Number))] + request.payload.nameservers = ids + + const { user, group } = request.auth.credentials + const userPerm = await Permission.getEffective(user.id) + const usable = new Set((userPerm?.nameserver?.usable ?? []).map(Number)) + + const unusable = [] + for (const nid of ids) { + if (usable.has(nid)) continue + const nsGid = await Authz.getObjectGroupId('nameserver', nid) + if (nsGid !== null && (await Authz.isInGroupTree(group.id, nsGid))) continue + unusable.push(nid) + } + return unusable +} + +function nameserversNotUsable(h, ids) { + return h.response({ meta: { api: meta.api, msg: `nameserver(s) not usable: ${ids.join(', ')}` } }).code(403) +} + +// single-zone responses carry the assignment; lists stay one query +async function withNameservers(zones) { + for (const zone of zones) zone.nameservers = await Zone.nameserverIds(zone.id) + return zones +} + function ZoneRoutes(server) { server.route([ { method: 'GET', path: '/zone/{id?}', options: { + app: { + permission: { + resource: 'zone', + action: 'read', + idFrom: 'params.id', + list: { resource: 'group', idFrom: 'query.gid', defaultToGroup: true }, + }, + }, validate: { query: validate.zone.GET_req, }, @@ -20,12 +78,16 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const deleted = request.query.deleted === true const getArgs = { - deleted, - limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, + limit: await pageLimit(request.query.limit), + } + if (request.query.deleted !== undefined) { + getArgs.deleted = request.query.deleted === true } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) + if (!request.params.id && request.query.gid == null) { + getArgs.gid = request.auth.credentials.group.id + } if (request.query.gid != null) { const gid = Number.isInteger(request.query.gid) ? request.query.gid @@ -43,20 +105,33 @@ 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), ]) + if (getArgs.id) await withNameservers(zones) return h .response({ @@ -79,6 +154,7 @@ function ZoneRoutes(server) { method: 'POST', path: '/zone', options: { + app: { permission: { resource: 'zone', action: 'create' } }, validate: { payload: validate.zone.POST, }, @@ -88,9 +164,19 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const id = await Zone.create(request.payload) + const unusable = await unusableNameservers(request) + if (unusable.length) return nameserversNotUsable(h, unusable) + + let id + try { + id = await Zone.create(request.payload) + } catch (err) { + if (err instanceof ZoneNameConflictError) return zoneNameConflict(h) + throw err + } - const zones = await Zone.get({ id }) + const zones = await withNameservers(await Zone.get({ id })) + await Audit.logZone(request.auth.credentials.user, 'added', zones[0]) return h .response({ @@ -107,6 +193,14 @@ function ZoneRoutes(server) { method: 'PUT', path: '/zone/{id}', options: { + app: { + permission: { + resource: 'zone', + action: 'write', + idFrom: 'params.id', + targetGroupFrom: 'payload.gid', + }, + }, validate: { payload: validate.zone.PUT, }, @@ -124,9 +218,28 @@ 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 unusable = await unusableNameservers(request) + if (unusable.length) return nameserversNotUsable(h, unusable) - const updated = await Zone.get({ id }) + const payload = Object.fromEntries( + Object.entries(request.payload).filter(([key]) => ZONE_PUT_FIELDS.has(key)), + ) + try { + await Zone.put({ id, ...payload }) + } catch (err) { + if (err instanceof ZoneNameConflictError) return zoneNameConflict(h) + throw err + } + + let updated = await Zone.get({ id }) + if (updated.length === 0) updated = await Zone.get({ id, deleted: true }) + await withNameservers(updated) + await Audit.logZone( + request.auth.credentials.user, + zoneAuditAction(zones[0], payload), + updated[0], + zones[0], + ) return h.response({ zone: updated, meta: { api: meta.api, msg: `the zone was updated` } }).code(200) }, }, @@ -134,6 +247,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, }, @@ -142,15 +256,7 @@ function ZoneRoutes(server) { handler: async (request, h) => { const zid = parseInt(request.params.id, 10) - const nsRows = await Mysql.execute( - `SELECT z.zone, n.name, n.ttl - FROM nt_zone_nameserver nzns - JOIN nt_nameserver n ON n.nt_nameserver_id = nzns.nt_nameserver_id - JOIN nt_zone z ON z.nt_zone_id = nzns.nt_zone_id - WHERE nzns.nt_zone_id = ? - ORDER BY n.name`, - [zid], - ) + const nsRows = await Zone.nameserversFor(zid) const ns = nsRows.map((row) => { const zoneFqdn = row.zone.endsWith('.') ? row.zone : `${row.zone}.` @@ -165,6 +271,7 @@ function ZoneRoutes(server) { method: 'DELETE', path: '/zone/{id}', options: { + app: { permission: { resource: 'zone', action: 'delete', idFrom: 'params.id' } }, validate: { query: validate.zone.DELETE, }, @@ -194,6 +301,7 @@ function ZoneRoutes(server) { id: zones[0].id, deleted: 1, }) + await Audit.logZone(request.auth.credentials.user, 'deleted', zones[0]) return h .response({ @@ -209,6 +317,22 @@ function ZoneRoutes(server) { ]) } +function zoneAuditAction(previous, payload) { + if (payload.deleted === true && previous.deleted !== true) return 'deleted' + if (payload.deleted === false && previous.deleted === true) return 'recovered' + if (payload.gid !== undefined && payload.gid !== previous.gid) return 'moved' + return 'modified' +} + +function zoneNameConflict(h) { + return h + .response({ + zone: [], + meta: { api: meta.api, msg: `Zone is already taken` }, + }) + .code(409) +} + export default ZoneRoutes export { Zone, ZoneRoutes } diff --git a/routes/zone.test.js b/routes/zone.test.js index 86c3eb7..e4b35e5 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -5,36 +5,70 @@ import { init } from './index.js' import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' +import Nameserver from '../lib/nameserver/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/zone.json' with { type: 'json' } +import nameserverCase from './test/nameserver.json' with { type: 'json' } let server let case2Id = 4094 +const duplicateId = 4092 +const duplicateCaseId = 4089 +const concurrentDuplicateIds = [4087, 4088] +const recoveryDuplicateId = 4086 const subGroup = { id: 4090, parent_gid: groupCase.id, name: 'sub.route.example.com' } const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.example.com.' } +// one nameserver in the caller's group, one it cannot use +const ownNs = { ...nameserverCase, id: 4093, gid: groupCase.id, name: 'own.ns.example.com.' } +const otherNs = { ...nameserverCase, id: 4092, gid: 1, name: 'other.ns.example.com.' } +const nsZoneId = 4089 +const refusedZoneId = 4085 before(async () => { await Zone.destroy({ id: nsCase.id }) await Zone.destroy({ id: case2Id }) + await Zone.destroy({ id: duplicateId }) + await Zone.destroy({ id: duplicateCaseId }) + await Zone.destroy({ id: recoveryDuplicateId }) + for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) await Zone.destroy({ id: subZone.id }) // Destroy the subgroup before recreating it: a lingering row would make // Group.create early-return and skip addToSubgroups, leaving the // 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) + await Zone.destroy({ id: nsZoneId }) + await Zone.destroy({ id: refusedZoneId }) + await Nameserver.destroy({ id: ownNs.id }) + await Nameserver.destroy({ id: otherNs.id }) + await Nameserver.create(ownNs) + await Nameserver.create(otherNs) server = await init() }) after(async () => { + await Zone.destroy({ id: nsZoneId }) + await Nameserver.destroy({ id: ownNs.id }) + await Nameserver.destroy({ id: otherNs.id }) + await Zone.destroy({ id: duplicateId }) + await Zone.destroy({ id: duplicateCaseId }) + await Zone.destroy({ id: recoveryDuplicateId }) + for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) await Zone.destroy({ id: subZone.id }) await Group.destroy({ id: subGroup.id }) + await Group.destroy({ id: case2Id }) await server.stop() }) @@ -75,6 +109,170 @@ describe('zone routes', () => { assert.ok(res.result.zone.some((z) => z.zone === nsCase.zone)) }) + it('POST /zone rejects an active duplicate name', async () => { + const duplicate = { ...nsCase, id: duplicateId, zone: `${nsCase.zone}.` } + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: duplicate, + }) + + assert.equal(res.statusCode, 409) + assert.deepEqual(res.result.zone, []) + assert.match(res.result.meta.msg, /already taken/) + }) + + it('POST /zone rejects a case-only duplicate name', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { ...nsCase, id: duplicateCaseId, zone: `${nsCase.zone.toUpperCase()}.` }, + }) + await Zone.destroy({ id: duplicateCaseId }) + + assert.equal(res.statusCode, 409) + assert.deepEqual(res.result.zone, []) + }) + + it('POST /zone admits only one concurrent canonical name', async () => { + const responses = await Promise.all( + concurrentDuplicateIds.map((id, index) => + server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { + ...nsCase, + id, + zone: index === 0 ? 'concurrent.example.com' : 'CONCURRENT.EXAMPLE.COM.', + }, + }), + ), + ) + for (const id of concurrentDuplicateIds) await Zone.destroy({ id }) + + assert.deepEqual(responses.map((res) => res.statusCode).sort(), [201, 409]) + }) + + it('PUT /zone refuses recovery beside an active canonical name', async () => { + await Zone.create({ + ...nsCase, + id: recoveryDuplicateId, + zone: `${nsCase.zone.toUpperCase()}.`, + deleted: true, + }) + + const res = await server.inject({ + method: 'PUT', + url: `/zone/${recoveryDuplicateId}`, + headers: auth.headers, + payload: { deleted: false }, + }) + + assert.equal(res.statusCode, 409) + assert.equal((await Zone.get({ id: recoveryDuplicateId, deleted: true }))[0].deleted, true) + }) + + it(`PUT /zone/${nsCase.id} moves it to another group`, async () => { + const moved = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { gid: case2Id }, + }) + assert.equal(moved.statusCode, 200) + assert.equal(moved.result.zone[0].gid, case2Id) + + const restored = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { gid: groupCase.id }, + }) + assert.equal(restored.statusCode, 200) + assert.equal(restored.result.zone[0].gid, groupCase.id) + }) + + it(`PUT /zone/${nsCase.id} validates serial and unknown fields`, async () => { + const updated = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { serial: 2026082601 }, + }) + assert.equal(updated.statusCode, 200) + assert.equal(updated.result.zone[0].serial, 2026082601) + + const unknown = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { definitely_not_a_zone_field: true }, + }) + assert.equal(unknown.statusCode, 400) + }) + + it(`PUT /zone/${nsCase.id} assigns a usable nameserver`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { nameservers: [ownNs.id, ownNs.id] }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.zone[0].nameservers, [ownNs.id]) + + const get = await server.inject({ method: 'GET', url: `/zone/${nsCase.id}`, headers: auth.headers }) + assert.deepEqual(get.result.zone[0].nameservers, [ownNs.id]) + }) + + it(`PUT /zone/${nsCase.id} refuses a nameserver the caller cannot use`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { nameservers: [ownNs.id, otherNs.id] }, + }) + assert.equal(res.statusCode, 403) + assert.match(res.result.meta.msg, /not usable: 4092/) + assert.deepEqual(await Zone.nameserverIds(nsCase.id), [ownNs.id]) + }) + + it(`PUT /zone/${nsCase.id} clears the assignment`, async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${nsCase.id}`, + headers: auth.headers, + payload: { nameservers: [] }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.zone[0].nameservers, []) + }) + + it(`POST /zone (${nsZoneId}) with nameservers`, async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { ...nsCase, id: nsZoneId, zone: 'ns.route.example.com.', nameservers: [ownNs.id] }, + }) + assert.equal(res.statusCode, 201) + assert.deepEqual(res.result.zone[0].nameservers, [ownNs.id]) + }) + + it('POST /zone rejects a nameserver the caller cannot use', async () => { + const res = await server.inject({ + method: 'POST', + url: '/zone', + headers: auth.headers, + payload: { ...nsCase, id: refusedZoneId, zone: 'ns2.route.example.com.', nameservers: [otherNs.id] }, + }) + assert.equal(res.statusCode, 403) + assert.equal((await Zone.get({ id: refusedZoneId })).length, 0) + }) + it(`POST /zone (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique @@ -90,6 +288,14 @@ describe('zone routes', () => { // console.log(res.result) assert.equal(res.statusCode, 201) assert.ok(res.result.zone[0].gid) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone?gid=${case2Id}&search=route2.example.com.`, + headers: auth.headers, + }) + assert.equal(log.statusCode, 200) + assert.equal(log.result.log[0].action, 'added') }) it(`GET /zone/${case2Id}`, async () => { @@ -111,6 +317,24 @@ describe('zone routes', () => { }) // console.log(res.result) assert.equal(res.statusCode, 200) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone?gid=${case2Id}&search=route2.example.com.`, + headers: auth.headers, + }) + assert.equal(log.statusCode, 200) + assert.equal(log.result.log[0].action, 'deleted') + }) + + it('GET /log/global includes subgroup activity', async () => { + const res = await server.inject({ + method: 'GET', + url: `/log/global?gid=${groupCase.id}&include_subgroups=true&search=route2.example.com.`, + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + assert.ok(res.result.log.some((row) => row.title === 'route2.example.com.')) }) it(`DELETE /zone/${case2Id}`, async () => { diff --git a/routes/zone_record.js b/routes/zone_record.js index 285cb33..5a001cd 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -2,6 +2,9 @@ import validate from '@nictool/validate' import ZoneRecord from '../lib/zone_record/index.js' import Zone from '../lib/zone/index.js' +import Authz from '../lib/authz/index.js' +import Audit from '../lib/audit/index.js' +import { pageLimit } from '../lib/page.js' import { meta } from '../lib/util.js' async function zoneRecordResponseFailAction(request, h, err) { @@ -33,6 +36,14 @@ function ZoneRecordRoutes(server) { method: 'GET', path: '/zone_record/{id?}', options: { + app: { + permission: { + resource: 'zonerecord', + action: 'read', + idFrom: 'params.id', + list: { resource: 'zone', idFrom: 'query.zid' }, + }, + }, validate: { query: validate.zone_record.GET_req, }, @@ -46,7 +57,7 @@ function ZoneRecordRoutes(server) { const deleted = request.query.deleted === true ? 1 : 0 const getArgs = { deleted, - limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, + limit: await pageLimit(request.query.limit), } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) if (request.query.zid) getArgs.zid = parseInt(request.query.zid, 10) @@ -55,7 +66,16 @@ function ZoneRecordRoutes(server) { if (request.query.sort_by) getArgs.sort_by = request.query.sort_by if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir - const scope = getArgs.id ? { id: getArgs.id } : getArgs.zid ? { zid: getArgs.zid } : {} + if (!getArgs.id && getArgs.zid) { + const ids = await Authz.getZoneRecordReadScope(request.auth.credentials.group.id, getArgs.zid) + if (ids !== null) getArgs.ids = ids + } + + const scope = getArgs.id + ? { id: getArgs.id } + : getArgs.zid + ? { zid: getArgs.zid, ...(getArgs.ids ? { ids: getArgs.ids } : {}) } + : {} const countArgs = { deleted, ...scope, @@ -90,6 +110,7 @@ function ZoneRecordRoutes(server) { method: 'POST', path: '/zone_record', options: { + app: { permission: { resource: 'zonerecord', action: 'create' } }, validate: { payload: validate.zone_record.POST, }, @@ -102,6 +123,8 @@ function ZoneRecordRoutes(server) { const id = await ZoneRecord.create(request.payload) const zrs = await ZoneRecord.get({ id }) + const zones = await Zone.get({ id: zrs[0].zid }) + await Audit.logZoneRecord(request.auth.credentials.user, 'added', zrs[0], zones[0]) return h .response({ @@ -118,6 +141,14 @@ function ZoneRecordRoutes(server) { method: 'PUT', path: '/zone_record/{id}', options: { + app: { + permission: { + resource: 'zonerecord', + action: 'write', + idFrom: 'params.id', + targetCreateResource: 'zonerecord', + }, + }, validate: { payload: validate.zone_record.PUT, }, @@ -136,7 +167,17 @@ function ZoneRecordRoutes(server) { await ZoneRecord.put({ id, ...request.payload }) - const updated = await ZoneRecord.get({ id }) + let updated = await ZoneRecord.get({ id }) + if (updated.length === 0) updated = await ZoneRecord.get({ id, deleted: true }) + let zones = await Zone.get({ id: updated[0].zid }) + if (zones.length === 0) zones = await Zone.get({ id: updated[0].zid, deleted: true }) + await Audit.logZoneRecord( + request.auth.credentials.user, + request.payload.deleted === true ? 'deleted' : 'modified', + updated[0], + zones[0], + zrs[0], + ) return h .response({ zone_record: updated, @@ -149,6 +190,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, }, @@ -174,10 +216,17 @@ function ZoneRecordRoutes(server) { .code(404) } + let zones = await Zone.get({ id: zrs[0].zid }) + if (zones.length === 0) zones = await Zone.get({ id: zrs[0].zid, deleted: true }) + if (zones.length === 0) { + return h.response({ meta: { api: meta.api, msg: `I couldn't find that zone` } }).code(404) + } + await ZoneRecord.delete({ id: zrs[0].id, deleted: 1, }) + await Audit.logZoneRecord(request.auth.credentials.user, 'deleted', zrs[0], zones[0]) const deletedZrs = await ZoneRecord.get({ id: zrs[0].id, diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index 5e96845..9fc79f0 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -6,8 +6,10 @@ import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' import ZoneRecord from '../lib/zone_record/index.js' +import Delegation from '../lib/delegation/index.js' import groupCase from './test/group.json' with { type: 'json' } +import { grantGroupPermissions } from './test/permissions.js' import userCase from './test/user.json' with { type: 'json' } import zoneCase from './test/zone.json' with { type: 'json' } @@ -17,6 +19,11 @@ const createdZoneRecordIds = [] const testGroupId = 5094 const testZoneId = 5095 const testZoneRecordId = 5096 +const deletedParentRecordId = 5097 +const delegatedGroupId = 5098 +const delegatedZoneId = 5099 +const delegatedRecordId = 5100 +const hiddenRecordId = 5101 const testZone = { ...zoneCase, @@ -34,8 +41,40 @@ const testZoneRecord = { address: '203.0.113.6', } +const deletedParentRecord = { + ...testZoneRecord, + id: deletedParentRecordId, + owner: 'deleted-parent.route-zr-delete.example.com.', +} + +const delegatedZone = { + ...zoneCase, + id: delegatedZoneId, + gid: delegatedGroupId, + zone: 'delegated-scope.route.example.com', +} + +const delegatedRecord = { + ...testZoneRecord, + id: delegatedRecordId, + zid: delegatedZoneId, + owner: 'allowed.delegated-scope.route.example.com.', +} + +const hiddenRecord = { + ...delegatedRecord, + id: hiddenRecordId, + owner: 'hidden.delegated-scope.route.example.com.', +} + before(async () => { + await Delegation.delete({ gid: testGroupId, oid: delegatedRecordId, type: 'ZONERECORD' }) + await ZoneRecord.destroy({ id: delegatedRecordId }) + await ZoneRecord.destroy({ id: hiddenRecordId }) + await Zone.destroy({ id: delegatedZoneId }) + await Group.destroy({ id: delegatedGroupId }) await ZoneRecord.destroy({ id: testZoneRecordId }) + await ZoneRecord.destroy({ id: deletedParentRecordId }) await Zone.destroy({ id: testZoneId }) const testGroup = { ...groupCase, id: testGroupId } @@ -49,19 +88,42 @@ before(async () => { await Group.create(testGroup) await User.create(testUser) + await grantGroupPermissions(testGroup.id) await Zone.create(testZone) await ZoneRecord.create(testZoneRecord) + await ZoneRecord.create(deletedParentRecord) + await Group.create({ + ...groupCase, + id: delegatedGroupId, + name: 'delegated-owner', + }) + await Zone.create(delegatedZone) + await ZoneRecord.create(delegatedRecord) + await ZoneRecord.create(hiddenRecord) + await Delegation.create({ + gid: testGroupId, + oid: delegatedRecordId, + type: 'ZONERECORD', + delegated_by_id: testGroupId, + delegated_by_name: `route-zr-delete-${testGroupId}`, + }) server = await init() }) after(async () => { + await Delegation.delete({ gid: testGroupId, oid: delegatedRecordId, type: 'ZONERECORD' }) + await ZoneRecord.destroy({ id: delegatedRecordId }) + await ZoneRecord.destroy({ id: hiddenRecordId }) + await Zone.destroy({ id: delegatedZoneId }) + await Group.destroy({ id: delegatedGroupId }) for (const id of createdZoneRecordIds) { await ZoneRecord.destroy({ id }) } await ZoneRecord.destroy({ id: testZoneRecordId }) + await ZoneRecord.destroy({ id: deletedParentRecordId }) await Zone.destroy({ id: testZoneId }) - await server.stop() + if (server) await server.stop() }) describe('zone_record routes', () => { @@ -82,6 +144,15 @@ describe('zone_record routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) + it('GET /log/zone_record requires zid', async () => { + const res = await server.inject({ + method: 'GET', + url: '/log/zone_record', + headers: auth.headers, + }) + assert.equal(res.statusCode, 400) + }) + it('POST /zone_record creates and returns array payload', async () => { const res = await server.inject({ method: 'POST', @@ -105,6 +176,34 @@ describe('zone_record routes', () => { createdZoneRecordIds.push(res.result.zone_record[0].id) }) + it('limits a delegated zone collection to delegated records', async () => { + const zones = await server.inject({ + method: 'GET', + url: `/zone?gid=${testGroupId}&search=delegated-scope`, + headers: auth.headers, + }) + assert.equal(zones.statusCode, 200) + assert.deepEqual( + zones.result.zone.map((zone) => zone.id), + [delegatedZoneId], + ) + assert.equal(zones.result.meta.pagination.total, 2) + assert.equal(zones.result.meta.pagination.filtered, 1) + + const records = await server.inject({ + method: 'GET', + url: `/zone_record?zid=${delegatedZoneId}`, + headers: auth.headers, + }) + assert.equal(records.statusCode, 200) + assert.deepEqual( + records.result.zone_record.map((record) => record.id), + [delegatedRecordId], + ) + assert.equal(records.result.meta.pagination.total, 1) + assert.equal(records.result.meta.pagination.filtered, 1) + }) + it('POST /zone_record accepts omitted ttl and stores 0', async () => { const res = await server.inject({ method: 'POST', @@ -152,22 +251,47 @@ describe('zone_record routes', () => { }) assert.equal(res.statusCode, 200) - if (res.result.zone_record.length !== 2) { - const all = await ZoneRecord.get({ zid: testZoneId }) - console.error('DIAGZR createdIds=', JSON.stringify(createdZoneRecordIds)) - console.error('DIAGZR limit-query result=', JSON.stringify(res.result.zone_record)) - console.error('DIAGZR filtered=', res.result.meta.pagination.filtered) - console.error( - 'DIAGZR all-records-for-zid=', - JSON.stringify(all.map((r) => ({ id: r.id, zid: r.zid, owner: r.owner, deleted: r.deleted }))), - ) - } assert.equal(res.result.zone_record.length, 2) assert.equal(res.result.zone_record[0].owner, `${token}0.route-zr-delete.example.com.`) assert.equal(res.result.meta.pagination.filtered, 3) assert.equal(res.result.meta.pagination.limit, 2) assert.equal(res.result.meta.pagination.offset, 0) assert.ok(res.result.meta.pagination.total >= 3) + + const byAddress = await server.inject({ + method: 'GET', + url: `/zone_record?zid=${testZoneId}&search=${token}&sort_by=address&sort_dir=desc`, + headers: auth.headers, + }) + assert.equal(byAddress.statusCode, 200) + assert.equal(byAddress.result.zone_record[0].address, '203.0.113.22') + }) + + it(`PUT /zone_record/${testZoneRecordId} can soft-delete and recover`, async () => { + const gone = await server.inject({ + method: 'PUT', + url: `/zone_record/${testZoneRecordId}`, + headers: auth.headers, + payload: { deleted: true }, + }) + assert.equal(gone.statusCode, 200) + assert.equal(gone.result.zone_record[0].deleted, true) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${testZoneId}&search=www.route-zr-delete`, + headers: auth.headers, + }) + assert.equal(log.result.log[0].action, 'deleted') + + const back = await server.inject({ + method: 'PUT', + url: `/zone_record/${testZoneRecordId}`, + headers: auth.headers, + payload: { deleted: false }, + }) + assert.equal(back.statusCode, 404, 'a deleted record is not editable through PUT') + await ZoneRecord.delete({ id: testZoneRecordId, deleted: 0 }) }) it(`DELETE /zone_record/${testZoneRecordId} soft-deletes record`, async () => { @@ -181,6 +305,15 @@ describe('zone_record routes', () => { assert.ok(Array.isArray(res.result.zone_record)) assert.equal(res.result.zone_record[0].id, testZoneRecordId) assert.equal(res.result.zone_record[0].deleted, true) + + const log = await server.inject({ + method: 'GET', + url: `/log/zone_record?zid=${testZoneId}&search=www.route-zr-delete`, + headers: auth.headers, + }) + assert.equal(log.statusCode, 200) + assert.equal(log.result.log[0].action, 'deleted') + assert.equal(log.result.log[0].owner, testZoneRecord.owner) }) it(`GET /zone_record/${testZoneRecordId} hides deleted by default`, async () => { @@ -216,6 +349,24 @@ describe('zone_record routes', () => { assert.equal(res.statusCode, 404) }) + it('DELETE /zone_record logs against a soft-deleted parent zone', async () => { + await Zone.delete({ id: testZoneId, deleted: true }) + let res + try { + res = await server.inject({ + method: 'DELETE', + url: `/zone_record/${deletedParentRecordId}`, + headers: auth.headers, + }) + } finally { + await Zone.delete({ id: testZoneId, deleted: false }) + } + + assert.equal(res.statusCode, 200) + assert.equal(res.result.zone_record[0].id, deletedParentRecordId) + assert.equal(res.result.zone_record[0].deleted, true) + }) + it('DELETE /session', async () => { const res = await server.inject({ method: 'DELETE', diff --git a/test/backends/json.sh b/test/backends/json.sh index d96349e..81ed93c 100755 --- a/test/backends/json.sh +++ b/test/backends/json.sh @@ -17,8 +17,6 @@ test_files() { } run_tests() { - # Run serially: the file store uses shared files; parallel workers cause concurrent-write corruption - for f in $(test_files); do - $NODE --test --test-reporter=spec "$f" || exit 1 - done + # shellcheck disable=SC2046 # word splitting is how the file list is passed + $NODE --test --test-concurrency=1 "$@" $(test_files) } diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh index fdcaf47..fe074c6 100755 --- a/test/backends/mysql.sh +++ b/test/backends/mysql.sh @@ -23,5 +23,5 @@ test_files() { run_tests() { # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --test-reporter=spec $(test_files) + $NODE --test --test-concurrency=1 "$@" $(test_files) } diff --git a/test/backends/toml.sh b/test/backends/toml.sh index 345cdf3..e09436e 100755 --- a/test/backends/toml.sh +++ b/test/backends/toml.sh @@ -17,8 +17,6 @@ test_files() { } run_tests() { - # Run serially: TOML uses shared files; parallel workers cause concurrent-write corruption - for f in $(test_files); do - $NODE --test --test-reporter=spec "$f" || exit 1 - done + # shellcheck disable=SC2046 # word splitting is how the file list is passed + $NODE --test --test-concurrency=1 "$@" $(test_files) } diff --git a/test/run.sh b/test/run.sh index 5c22c5d..48ae343 100755 --- a/test/run.sh +++ b/test/run.sh @@ -39,19 +39,18 @@ if [ $# -ge 1 ]; then if [ "$1" = "watch" ]; then $NODE --test --watch elif [ "$1" = "coverage" ]; then - # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --experimental-test-coverage $COVERAGE_EXCLUDE $(test_files) + # shellcheck disable=SC2086 # word splitting passes each exclusion separately + run_tests --experimental-test-coverage $COVERAGE_EXCLUDE elif [ "$1" = "coverage:lcov" ]; then mkdir -p coverage # lcov alone writes only to the file, so a failure exits 1 with an empty log - # shellcheck disable=SC2046 # word splitting is how the file list is passed - $NODE --test --experimental-test-coverage $COVERAGE_EXCLUDE \ + # shellcheck disable=SC2086 # word splitting passes each exclusion separately + run_tests --experimental-test-coverage $COVERAGE_EXCLUDE \ --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \ - --test-reporter=spec --test-reporter-destination=stdout \ - $(test_files) + --test-reporter=spec --test-reporter-destination=stdout else $NODE --test --test-reporter=spec "$1" fi else - run_tests + run_tests --test-reporter=spec fi