Skip to content

Commit ddc1f18

Browse files
add authz enforcement layer, port v2 permission model to v3
v3 didn't have permission enforcement yet -- this adds it. Hapi onPreHandler plugin reads route metadata and runs checks before handlers execute, rather than scattering permission calls inside each one. authz.js is the engine (checkPermission, group tree walks, delegation lookups). authz-plugin.js wires it into Hapi's request lifecycle. All routes annotated with what they need. Delegation routes now cap submitted permissions by the caller's own permissions at write time, which matches how v2 does it. Unit tests for the Authz class and integration tests via server.inject() included. v2 xt permission tests (14_permissions, 20_permission) should still pass -- 4892/4892 last run. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8561a86 commit ddc1f18

File tree

20 files changed

+1896
-35
lines changed

20 files changed

+1896
-35
lines changed

lib/authz-plugin.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import Authz from './authz.js'
2+
import Mysql from './mysql.js'
3+
4+
const TYPE_TO_RESOURCE = {
5+
ZONE: 'zone',
6+
ZONERECORD: 'zonerecord',
7+
NAMESERVER: 'nameserver',
8+
GROUP: 'group',
9+
}
10+
11+
const authzPlugin = {
12+
name: 'nt-authz',
13+
register(server) {
14+
server.ext('onPreHandler', async (request, h) => {
15+
const permCfg = request.route.settings.app?.permission
16+
if (!permCfg) return h.continue
17+
18+
if (!request.auth.isAuthenticated) return h.continue
19+
20+
let { resource, action } = permCfg
21+
const { idFrom } = permCfg
22+
const credentials = request.auth.credentials
23+
24+
let objectId
25+
if (idFrom) {
26+
objectId = resolveId(request, idFrom)
27+
if (objectId !== undefined) objectId = Number(objectId)
28+
}
29+
30+
// List requests (no objectId) don't need per-object authz
31+
if (action === 'read' && objectId === undefined) {
32+
return h.continue
33+
}
34+
35+
// Delegation: resolve resource from the type field
36+
if (action === 'delegate') {
37+
const type = request.payload?.type ?? request.query?.type
38+
if (type && TYPE_TO_RESOURCE[type]) {
39+
resource = TYPE_TO_RESOURCE[type]
40+
}
41+
}
42+
43+
let opts
44+
if (action === 'create') {
45+
const targetGid = await resolveTargetGroup(
46+
request, resource,
47+
)
48+
if (targetGid) opts = { targetGroupId: targetGid }
49+
}
50+
51+
const result = await Authz.checkPermission(
52+
credentials, resource, action, objectId, opts,
53+
)
54+
55+
if (result.allowed) return h.continue
56+
57+
return h.response({
58+
error_code: result.code,
59+
error_msg: result.msg,
60+
}).code(403).takeover()
61+
})
62+
},
63+
}
64+
65+
function resolveId(request, idFrom) {
66+
const [source, key] = idFrom.split('.')
67+
if (source === 'params') return request.params[key]
68+
if (source === 'payload') return request.payload?.[key]
69+
if (source === 'query') return request.query?.[key]
70+
}
71+
72+
async function resolveTargetGroup(request, resource) {
73+
const gid = request.payload?.gid
74+
?? request.payload?.nt_group_id
75+
?? request.payload?.parent_gid
76+
if (gid) return Number(gid)
77+
78+
if (resource === 'zonerecord') {
79+
const zid = request.payload?.zid ?? request.payload?.nt_zone_id
80+
if (zid) {
81+
const rows = await Mysql.execute(
82+
'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?',
83+
[zid],
84+
)
85+
if (rows.length > 0) return rows[0].nt_group_id
86+
}
87+
}
88+
89+
return null
90+
}
91+
92+
export default authzPlugin

lib/authz.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import Mysql from './mysql.js'
2+
import Permission from './permission.js'
3+
4+
const RESOURCE_QUERIES = {
5+
zone: 'SELECT nt_group_id FROM nt_zone WHERE nt_zone_id = ?',
6+
zonerecord: `SELECT z.nt_group_id FROM nt_zone_record r
7+
JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id
8+
WHERE r.nt_zone_record_id = ?`,
9+
user: 'SELECT nt_group_id FROM nt_user WHERE nt_user_id = ?',
10+
group: 'SELECT parent_group_id AS nt_group_id FROM nt_group WHERE nt_group_id = ?',
11+
nameserver: 'SELECT nt_group_id FROM nt_nameserver WHERE nt_nameserver_id = ?',
12+
}
13+
14+
const DELEGATE_TYPE = {
15+
zone: 'ZONE',
16+
zonerecord: 'ZONERECORD',
17+
nameserver: 'NAMESERVER',
18+
group: 'GROUP',
19+
}
20+
21+
const PERM_FIELDS = [
22+
'group_write', 'group_create', 'group_delete',
23+
'zone_write', 'zone_create', 'zone_delegate', 'zone_delete',
24+
'zonerecord_write', 'zonerecord_create', 'zonerecord_delegate', 'zonerecord_delete',
25+
'user_write', 'user_create', 'user_delete',
26+
'nameserver_write', 'nameserver_create', 'nameserver_delete',
27+
]
28+
29+
class Authz {
30+
async checkPermission(credentials, resource, action, objectId, opts) {
31+
const perm = await Permission.getEffective(credentials.user.id)
32+
if (!perm) return deny(`No permissions found`)
33+
34+
if (action === 'create') {
35+
if (perm[resource]?.create !== true) {
36+
return deny(`Not allowed to create new ${resource}`)
37+
}
38+
const targetGid = opts?.targetGroupId
39+
if (targetGid) {
40+
const inTree = await this.isInGroupTree(
41+
credentials.group.id, targetGid,
42+
)
43+
if (!inTree) {
44+
return deny(
45+
`No Access Allowed to that object`
46+
+ ` (${DELEGATE_TYPE[resource] ?? 'GROUP'} : ${targetGid})`,
47+
)
48+
}
49+
}
50+
return allow()
51+
}
52+
53+
if (resource === 'user' && objectId === credentials.user.id) {
54+
if (action === 'delete') return deny(`Not allowed to delete self`)
55+
if (action === 'write') {
56+
if (perm.self_write !== true) return deny(`Not allowed to modify self`)
57+
return allow()
58+
}
59+
return allow()
60+
}
61+
62+
if (resource === 'group' && objectId === credentials.group.id) {
63+
if (action === 'write') return deny(`Not allowed to edit your own group`)
64+
if (action === 'delete') return deny(`Not allowed to delete your own group`)
65+
}
66+
67+
if (resource === 'nameserver' && action === 'read') {
68+
const usable = perm.nameserver?.usable ?? []
69+
if (usable.includes(String(objectId))) return allow()
70+
}
71+
72+
const objGroupId = await this.getObjectGroupId(resource, objectId)
73+
if (objGroupId === null) {
74+
return deny(`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`)
75+
}
76+
77+
if (await this.isInGroupTree(credentials.group.id, objGroupId)) {
78+
if (action === 'read') return allow()
79+
if (perm[resource]?.[action] === true) return allow()
80+
return deny(`You have no '${action}' permission for ${resource} objects`)
81+
}
82+
83+
const delegation = await this.getDelegateAccess(
84+
credentials.group.id, objectId, resource,
85+
)
86+
if (delegation) {
87+
if (action === 'read') return allow()
88+
const permField = `perm_${action === 'delegate' ? 'delegate' : action}`
89+
if (delegation[permField] === 1) return allow()
90+
return deny(`You have no '${action}' permission for the delegated object`)
91+
}
92+
93+
return deny(
94+
`No Access Allowed to that object (${DELEGATE_TYPE[resource]} : ${objectId})`,
95+
)
96+
}
97+
98+
async getObjectGroupId(resource, objectId) {
99+
const query = RESOURCE_QUERIES[resource]
100+
if (!query) return null
101+
102+
const rows = await Mysql.execute(query, [objectId])
103+
if (rows.length === 0) return null
104+
105+
let gid = rows[0].nt_group_id
106+
if (resource === 'group' && (gid === 0 || gid === null)) gid = 1
107+
return gid
108+
}
109+
110+
async isInGroupTree(userGroupId, targetGroupId) {
111+
if (userGroupId === targetGroupId) return true
112+
113+
const rows = await Mysql.execute(
114+
`SELECT COUNT(*) AS count FROM nt_group_subgroups
115+
WHERE nt_group_id = ? AND nt_subgroup_id = ?`,
116+
[userGroupId, targetGroupId],
117+
)
118+
return rows[0].count > 0
119+
}
120+
121+
async getDelegateAccess(groupId, objectId, resource) {
122+
const type = DELEGATE_TYPE[resource]
123+
if (!type) return null
124+
125+
const rows = await Mysql.execute(
126+
`SELECT * FROM nt_delegate
127+
WHERE nt_group_id = ? AND nt_object_id = ? AND nt_object_type = ? AND deleted = 0`,
128+
[groupId, objectId, type],
129+
)
130+
if (rows.length > 0) return rows[0]
131+
132+
if (resource === 'zonerecord') {
133+
return this.getZoneRecordPseudoDelegation(groupId, objectId)
134+
}
135+
return null
136+
}
137+
138+
async getZoneRecordPseudoDelegation(groupId, zoneRecordId) {
139+
const rows = await Mysql.execute(
140+
`SELECT d.*, 1 AS pseudo FROM nt_delegate d
141+
JOIN nt_zone_record r ON r.nt_zone_id = d.nt_object_id
142+
WHERE d.nt_group_id = ?
143+
AND r.nt_zone_record_id = ?
144+
AND d.nt_object_type = 'ZONE'
145+
AND d.deleted = 0`,
146+
[groupId, zoneRecordId],
147+
)
148+
return rows.length > 0 ? rows[0] : null
149+
}
150+
151+
capPermissions(userPerm, targetPerms) {
152+
if (!targetPerms || !userPerm) return targetPerms
153+
154+
const capped = { ...targetPerms }
155+
for (const field of PERM_FIELDS) {
156+
if (capped[field] === undefined) continue
157+
const [resource] = field.split('_', 2)
158+
const remaining = field.slice(resource.length + 1)
159+
if (userPerm[resource]?.[remaining] !== true) {
160+
delete capped[field]
161+
}
162+
}
163+
return capped
164+
}
165+
}
166+
167+
function allow() {
168+
return { allowed: true }
169+
}
170+
171+
function deny(msg) {
172+
return { allowed: false, code: 404, msg }
173+
}
174+
175+
export default new Authz()

0 commit comments

Comments
 (0)