Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
8d74c22
authz: port the v2 permission model to v3
burning-bush-dev Apr 12, 2026
8a26b99
user: add searchable listings
aberoham Aug 22, 2026
7bca350
objects: move between groups
aberoham Aug 22, 2026
1f2571d
log: persist and serve audit entries
aberoham Aug 22, 2026
6cf40d8
zone: reject duplicate names
aberoham Aug 22, 2026
ab4286a
test: serialize mysql suites
aberoham Aug 22, 2026
3a3f85d
stores: audit, authz, delegation behind stores
aberoham Aug 23, 2026
d3142f4
test: json file store coverage for new stores
aberoham Aug 23, 2026
40d7bee
stores: align file backend behavior
aberoham Aug 24, 2026
8394799
user: keep moves and totals in sync
aberoham Aug 24, 2026
b4a6965
zone: enforce canonical names atomically
aberoham Aug 24, 2026
7de3129
zone record: keep scoped reads, deletion audit
aberoham Aug 24, 2026
9471324
user: keep self_write from editing own permissions
aberoham Aug 25, 2026
69c28a2
authz: moving a record out needs delete access
aberoham Aug 25, 2026
28773ef
session: measure idle time from activity
aberoham Aug 25, 2026
f62128f
test: keep session activity store-neutral
aberoham Aug 26, 2026
2fe42be
bridge: enforce validate request contracts
aberoham Aug 26, 2026
149a54b
ci: test proposed validate source
aberoham Aug 26, 2026
f22b27a
test: await user route cleanup
aberoham Aug 26, 2026
a3c0087
server: await store disconnection
aberoham Aug 26, 2026
26eaea5
test: serialize coverage and force runner exit
aberoham Aug 26, 2026
0d1d719
zone: limit advisory lock names
aberoham Aug 26, 2026
73a17f6
authz: keep moves inside the owning tree
aberoham Aug 26, 2026
5b248ec
zone record: sort by the legacy rdata columns
aberoham Aug 26, 2026
629307c
session: catch a failed activity touch
aberoham Aug 26, 2026
fcf2c85
test: drop the forced runner exit
aberoham Aug 26, 2026
9b72283
ci: test the proposed validate at a4d0785
aberoham Aug 26, 2026
d44378e
deps: require validate 1.0.0
aberoham Aug 28, 2026
8d35ec8
log: scope record history to readable records
aberoham Aug 28, 2026
f9744d5
authz: default a delegation read to zones
aberoham Aug 28, 2026
f53b830
zone_record: let PUT soft-delete without a 500
aberoham Aug 28, 2026
cd36b58
delegation: serialize duplicate check and insert
aberoham Aug 28, 2026
13826e2
permission: file store honours deleted rows, gids
aberoham Aug 28, 2026
4c6c86e
zone: assign nameservers through the api
aberoham Aug 28, 2026
7a64bad
ci: test against validate main until #31 ships
aberoham Aug 29, 2026
fdf17bc
file stores: serialize writes to a file
aberoham Aug 29, 2026
4f05c0d
zone: allocate ids in the file store
aberoham Aug 29, 2026
3d41527
authz: cover record-scoped history reads
aberoham Aug 29, 2026
c3e23d7
delegation: test racing creates on the json store
aberoham Aug 29, 2026
ca76ae4
chore: prettier the branch
aberoham Aug 29, 2026
0229a7e
authz: accept a 0 is_admin on a v2 schema
aberoham Aug 29, 2026
b516a4f
authz: align file-store permission values
aberoham Aug 29, 2026
91af63c
test: cover the delegation route lifecycle
aberoham Aug 29, 2026
9ffa289
test: race delegation creates on every store
aberoham Aug 29, 2026
e4bd365
test: cover authz mutation boundaries
aberoham Aug 29, 2026
fff43e5
test: drop the narration from file-stores
aberoham Aug 29, 2026
5bc6d7c
test: run authz on file stores
aberoham Aug 29, 2026
0260ff8
cov: measure the json store too
aberoham Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
39 changes: 35 additions & 4 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ services:
build:
context: ..
dockerfile: docker/Dockerfile
args:
NICTOOL_VALIDATE_SPEC: ${NICTOOL_VALIDATE_SPEC:-}
ports:
- '${API_PORT:-3000}:3000'
depends_on:
Expand Down
98 changes: 98 additions & 0 deletions lib/audit.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
24 changes: 24 additions & 0 deletions lib/audit/index.js
Original file line number Diff line number Diff line change
@@ -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()
130 changes: 130 additions & 0 deletions lib/audit/store/base.js
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading