@andrian.yablonskyy/thub-coordinator 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/thub-admin.js +16 -3
- package/package.json +1 -1
- package/src/server.js +1 -1
- package/src/services/admin-users.js +8 -4
- package/src/web/routes.js +53 -2
- package/test/users.test.js +54 -0
- package/views/admin/users.pug +15 -14
- package/views/help/_coordinator.pug +2 -0
package/bin/thub-admin.js
CHANGED
|
@@ -37,8 +37,21 @@ const { loadConfig } = require('../src/config'),
|
|
|
37
37
|
{ roleLabel, normalizeRole } = require('../src/services/admin-users'),
|
|
38
38
|
{ PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
|
|
39
39
|
{ spawnSync } = require('node:child_process'),
|
|
40
|
+
os = require('node:os'),
|
|
40
41
|
{ version: installedVersion } = require('../package.json');
|
|
41
42
|
|
|
43
|
+
// Who the audit log names for a change made here: the OS user running it.
|
|
44
|
+
function cliActor(){
|
|
45
|
+
let name = process.env.SUDO_USER || '';
|
|
46
|
+
try {
|
|
47
|
+
name ||= os.userInfo().username;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// no passwd entry (a container's random uid)
|
|
51
|
+
}
|
|
52
|
+
return name ? `thub-admin (${name})` : 'thub-admin';
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
function usage(){
|
|
43
56
|
console.log(`Usage:
|
|
44
57
|
thub-admin user add <username> --email <email> [--role user|maintainer|admin] [--password <p>] [--key]
|
|
@@ -174,7 +187,7 @@ function main(){
|
|
|
174
187
|
if (cmd === 'user'){
|
|
175
188
|
const { positional, flags } = parseFlags(rest),
|
|
176
189
|
[username, arg] = positional,
|
|
177
|
-
by =
|
|
190
|
+
by = cliActor(),
|
|
178
191
|
need = (u) => {
|
|
179
192
|
const row = adminUsers.getByUsername(u || '');
|
|
180
193
|
if (!row){
|
|
@@ -221,7 +234,7 @@ function main(){
|
|
|
221
234
|
if (!arg){
|
|
222
235
|
return usage(), process.exit(4);
|
|
223
236
|
}
|
|
224
|
-
adminUsers.resetPassword({ username: target.username, password: arg });
|
|
237
|
+
adminUsers.resetPassword({ username: target.username, password: arg }, { by });
|
|
225
238
|
console.log(`Password of "${target.username}" set`);
|
|
226
239
|
}
|
|
227
240
|
else if (sub === 'key'){
|
|
@@ -252,7 +265,7 @@ function main(){
|
|
|
252
265
|
if (!username || !password){
|
|
253
266
|
return usage(), process.exit(4);
|
|
254
267
|
}
|
|
255
|
-
adminUsers.create({ username, password, role: flags.role || 'admin' });
|
|
268
|
+
adminUsers.create({ username, password, role: flags.role || 'admin' }, { by: cliActor() });
|
|
256
269
|
console.log(`Created ${roleLabel(normalizeRole(flags.role || 'admin'))} user "${username}"`);
|
|
257
270
|
return;
|
|
258
271
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
package/src/server.js
CHANGED
|
@@ -247,7 +247,7 @@ function ensureBootstrapAdmin(services){
|
|
|
247
247
|
return;
|
|
248
248
|
}
|
|
249
249
|
const username = process.env.THUB_BOOTSTRAP_ADMIN_USER || 'admin';
|
|
250
|
-
services.adminUsers.resetPassword({ username, password });
|
|
250
|
+
services.adminUsers.resetPassword({ username, password }, { by: 'system (THUB_BOOTSTRAP_ADMIN_PASSWORD)' });
|
|
251
251
|
console.log(`Reset password for admin user "${username}" from THUB_BOOTSTRAP_ADMIN_PASSWORD`);
|
|
252
252
|
}
|
|
253
253
|
|
|
@@ -123,7 +123,10 @@ function parseListPrefs(json){
|
|
|
123
123
|
// access key for the Agent for anyone. `events` (the audit log) is optional
|
|
124
124
|
// so the profile/password functions can be used on their own (tests).
|
|
125
125
|
function createAdminUsersService(db, { events } = {}){
|
|
126
|
-
|
|
126
|
+
// Every user event names who did it (`by`): a username, `thub-admin
|
|
127
|
+
// (<os user>)`, or `system` for what no person did (the bootstrap reset,
|
|
128
|
+
// internal callers that pass none).
|
|
129
|
+
const audit = (id, type, data) => events?.record('user', id, type, { ...data, by: data.by || 'system' });
|
|
127
130
|
|
|
128
131
|
function getByUsername(username){
|
|
129
132
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
@@ -228,13 +231,14 @@ function createAdminUsersService(db, { events } = {}){
|
|
|
228
231
|
// silently promote anyone to admin), or creates one as admin if the
|
|
229
232
|
// named account doesn't exist yet — the original first-run bootstrap
|
|
230
233
|
// case, now handled by the same path.
|
|
231
|
-
function resetPassword({ username, password, role = 'admin' }){
|
|
234
|
+
function resetPassword({ username, password, role = 'admin' }, { by } = {}){
|
|
232
235
|
const existing = getByUsername(username);
|
|
233
236
|
if (existing){
|
|
234
237
|
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(hashPassword(password), existing.id);
|
|
238
|
+
audit(existing.id, 'user.password_set', { by });
|
|
235
239
|
return { id: existing.id, username, role: existing.role };
|
|
236
240
|
}
|
|
237
|
-
return create({ username, password, role });
|
|
241
|
+
return create({ username, password, role }, { by });
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
// Users page: a new temporary password for someone who lost theirs.
|
|
@@ -266,7 +270,7 @@ function createAdminUsersService(db, { events } = {}){
|
|
|
266
270
|
throw bad('Choose a password different from the current one');
|
|
267
271
|
}
|
|
268
272
|
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
|
|
269
|
-
audit(id, 'user.password_changed', {});
|
|
273
|
+
audit(id, 'user.password_changed', { by: row.username });
|
|
270
274
|
}
|
|
271
275
|
|
|
272
276
|
// Async (thread pool); the same work whether or not the user exists, or
|
package/src/web/routes.js
CHANGED
|
@@ -1205,7 +1205,58 @@ function createWebRouter({ services, config }){
|
|
|
1205
1205
|
// dashboard role.
|
|
1206
1206
|
isAdmin = (actor) => actor.role === 'admin',
|
|
1207
1207
|
managesUser = (actor, u) => Boolean(u) && (isAdmin(actor) || u.role === 'user' || u.id === actor.id),
|
|
1208
|
-
userFiltersFor = (actor) => (isAdmin(actor) ? USER_ROLE_FILTERS : ['user', 'blocked'])
|
|
1208
|
+
userFiltersFor = (actor) => (isAdmin(actor) ? USER_ROLE_FILTERS : ['user', 'blocked']),
|
|
1209
|
+
// The audit log's Action column names changed fields like this.
|
|
1210
|
+
FIELD_LABEL = { username: 'username', email: 'email', role: 'role', first_name: 'first name', last_name: 'last name' };
|
|
1211
|
+
|
|
1212
|
+
// The audit log's Action column: what was done, to whom. `nameOf` gives
|
|
1213
|
+
// the account's current username (else the one its events recorded).
|
|
1214
|
+
function describeUserEvent(e, nameOf){
|
|
1215
|
+
const name = nameOf(e.entity_id),
|
|
1216
|
+
whose = e.data.by === name ? 'their own' : `${name}'s`;
|
|
1217
|
+
switch (e.type){
|
|
1218
|
+
case 'user.created': return `Created ${name} (${roleLabel(e.data.role)})`;
|
|
1219
|
+
case 'user.updated': {
|
|
1220
|
+
const parts = [];
|
|
1221
|
+
if (e.data.role?.from){
|
|
1222
|
+
parts.push(`role ${roleLabel(e.data.role.from)} → ${roleLabel(e.data.role.to)}`);
|
|
1223
|
+
}
|
|
1224
|
+
const fields = (e.data.changed || []).filter((f) => f !== 'role').map((f) => FIELD_LABEL[f] || f);
|
|
1225
|
+
if (fields.length){
|
|
1226
|
+
parts.push(fields.join(', '));
|
|
1227
|
+
}
|
|
1228
|
+
return `Edited ${name}${parts.length ? `: ${parts.join('; ')}` : ''}`;
|
|
1229
|
+
}
|
|
1230
|
+
case 'user.blocked': return `Blocked ${name}`;
|
|
1231
|
+
case 'user.unblocked': return `Unblocked ${name}`;
|
|
1232
|
+
case 'user.deleted': return `Deleted ${name}`;
|
|
1233
|
+
case 'user.password_reset': return `Issued a temporary password for ${name}`;
|
|
1234
|
+
case 'user.password_set': return `Set ${whose} password`;
|
|
1235
|
+
case 'user.password_changed': return `Changed ${whose} password`;
|
|
1236
|
+
case 'user.key_created': return `Created ${whose} access key`;
|
|
1237
|
+
case 'user.key_rotated': return `Replaced ${whose} access key`;
|
|
1238
|
+
case 'user.key_revoked': return `Revoked ${whose} access key`;
|
|
1239
|
+
default: return `${e.type.replace('user.', '').replace(/_/g, ' ')} ${name}`;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Rows for the audit log: Timestamp, User (who did it — always shown;
|
|
1244
|
+
// events from before it was recorded for everything say `system`, or the
|
|
1245
|
+
// account itself for its own password change), Action.
|
|
1246
|
+
function auditRows(events){
|
|
1247
|
+
const names = new Map(services.adminUsers.list().map((u) => [u.id, u.username]));
|
|
1248
|
+
for (const e of services.adminUsers.recentEvents(1000)){
|
|
1249
|
+
if (!names.has(e.entity_id) && e.data.username){
|
|
1250
|
+
names.set(e.entity_id, e.data.username);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const nameOf = (id) => names.get(id) || id;
|
|
1254
|
+
return events.map((e) => ({
|
|
1255
|
+
ts: e.ts,
|
|
1256
|
+
by: e.data.by || (e.type === 'user.password_changed' ? nameOf(e.entity_id) : 'system'),
|
|
1257
|
+
action: describeUserEvent(e, nameOf)
|
|
1258
|
+
}));
|
|
1259
|
+
}
|
|
1209
1260
|
|
|
1210
1261
|
// A maintainer's Recent activity: about the users they see, and deleted
|
|
1211
1262
|
// ones that were Agent-only users when created — nothing about admins or
|
|
@@ -1266,7 +1317,7 @@ function createWebRouter({ services, config }){
|
|
|
1266
1317
|
admin: 'Everything'
|
|
1267
1318
|
},
|
|
1268
1319
|
roleTone: { user: 'secondary', maintainer: 'info', admin: 'primary' },
|
|
1269
|
-
|
|
1320
|
+
audit: auditRows(isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds)),
|
|
1270
1321
|
...extra
|
|
1271
1322
|
});
|
|
1272
1323
|
}
|
package/test/users.test.js
CHANGED
|
@@ -384,3 +384,57 @@ test('maintainers manage Agent-only users, their own account and CI tokens — n
|
|
|
384
384
|
assert.match(await r.text(), new RegExp(`<title>${job.id} `));
|
|
385
385
|
}
|
|
386
386
|
});
|
|
387
|
+
|
|
388
|
+
test('audit log: Timestamp, User (who did it — always), Action — from the dashboard, the profile, the Agent and thub-admin', async (t) => {
|
|
389
|
+
const { base, services, file } = await start(t),
|
|
390
|
+
u = services.adminUsers;
|
|
391
|
+
u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' }, { by: 'root' });
|
|
392
|
+
const login = async (name, pw) => (await fetch(`${base}/login`, {
|
|
393
|
+
method: 'POST', body: new URLSearchParams({ username: name, password: pw }), redirect: 'manual'
|
|
394
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
395
|
+
root = await login('root', 'pw'),
|
|
396
|
+
post = (cookie, p, body = {}) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie }, body: new URLSearchParams(body), redirect: 'manual' });
|
|
397
|
+
|
|
398
|
+
// Dashboard (an admin), then the profile (the user themselves).
|
|
399
|
+
await post(root, '/admin/users', { username: 'dev', email: 'd@example.com', role: 'user', issueKey: '1' });
|
|
400
|
+
const dev = u.getByUsername('dev');
|
|
401
|
+
await post(root, '/admin/users', { username: 'ops', email: 'o@example.com', role: 'maintainer' });
|
|
402
|
+
const ops = u.getByUsername('ops');
|
|
403
|
+
await post(root, `/admin/users/${ops.id}`, { username: 'ops', email: 'ops@example.com', role: 'admin', firstName: 'Olga' });
|
|
404
|
+
const temp = u.adminResetPassword(ops.id, { by: 'root' }),
|
|
405
|
+
opsCookie = await login('ops', temp);
|
|
406
|
+
await post(opsCookie, '/profile/password', { currentPassword: temp, newPassword: 'Ops-own-1', confirmPassword: 'Ops-own-1' });
|
|
407
|
+
|
|
408
|
+
// The Agent rotating its own key.
|
|
409
|
+
const devKey = u.issueKey(dev.id, { by: 'root' });
|
|
410
|
+
assert.equal((await fetch(`${base}/api/v1/me/key/rotate`, { method: 'POST', headers: { authorization: `Bearer ${devKey}` } })).status, 200);
|
|
411
|
+
|
|
412
|
+
// thub-admin on the Coordinator host, and an internal call naming no one.
|
|
413
|
+
const cli = spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'thub-admin.js'), 'user', 'block', 'dev'], {
|
|
414
|
+
env: { ...process.env, THUB_COORDINATOR_CONFIG: file, SUDO_USER: 'alice' }, encoding: 'utf8'
|
|
415
|
+
});
|
|
416
|
+
assert.match(cli.stdout, /Blocked "dev"/);
|
|
417
|
+
u.setBlocked(dev.id, false);
|
|
418
|
+
|
|
419
|
+
// Every recorded event names who did it.
|
|
420
|
+
assert.ok(u.recentEvents(100).every((e) => e.data.by), 'every user event has `by`');
|
|
421
|
+
|
|
422
|
+
const html = await (await fetch(`${base}/admin/users`, { headers: { cookie: root } })).text(),
|
|
423
|
+
log = html.slice(html.indexOf('Recent activity'), html.indexOf('id="addUserModal"')),
|
|
424
|
+
rows = [...log.matchAll(/<tr><td[^>]*>[^<]*<\/td><td[^>]*>([^<]*)<\/td><td>([^<]*)<\/td><\/tr>/g)]
|
|
425
|
+
.map((m) => [m[1], m[2].replace(/'/g, '\'').replace(/>/g, '>')]).reverse(); // oldest first
|
|
426
|
+
assert.match(log, /<th class="text-nowrap">Timestamp<\/th><th>User<\/th><th>Action<\/th>/);
|
|
427
|
+
assert.deepEqual(rows, [
|
|
428
|
+
['root', 'Created root (Admin)'],
|
|
429
|
+
['root', 'Created dev (User)'],
|
|
430
|
+
['root', 'Created dev\'s access key'],
|
|
431
|
+
['root', 'Created ops (Maintainer)'],
|
|
432
|
+
['root', 'Edited ops: role Maintainer → Admin; email, first name'],
|
|
433
|
+
['root', 'Issued a temporary password for ops'],
|
|
434
|
+
['ops', 'Changed their own password'],
|
|
435
|
+
['root', 'Replaced dev\'s access key'],
|
|
436
|
+
['dev', 'Replaced their own access key'],
|
|
437
|
+
['thub-admin (alice)', 'Blocked dev'],
|
|
438
|
+
['system', 'Unblocked dev']
|
|
439
|
+
]);
|
|
440
|
+
});
|
package/views/admin/users.pug
CHANGED
|
@@ -195,21 +195,22 @@ block content
|
|
|
195
195
|
tr
|
|
196
196
|
td.text-body-secondary(colspan="5")= q || roleFilter ? 'No user matches.' : 'No users yet.'
|
|
197
197
|
|
|
198
|
+
//- The audit log: who did what to which account (web/routes.js auditRows).
|
|
198
199
|
h2.h6.mt-4 Recent activity
|
|
199
|
-
if
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
200
|
+
if audit.length
|
|
201
|
+
.table-responsive.mb-4
|
|
202
|
+
table.table.table-sm.small.align-middle
|
|
203
|
+
thead
|
|
204
|
+
tr
|
|
205
|
+
th.text-nowrap Timestamp
|
|
206
|
+
th User
|
|
207
|
+
th Action
|
|
208
|
+
tbody
|
|
209
|
+
each e in audit
|
|
210
|
+
tr
|
|
211
|
+
td.text-nowrap.text-body-secondary= fmtDate(e.ts)
|
|
212
|
+
td.text-nowrap= e.by
|
|
213
|
+
td= e.action
|
|
213
214
|
else
|
|
214
215
|
p.small.text-body-secondary Nothing yet.
|
|
215
216
|
|
|
@@ -131,6 +131,8 @@
|
|
|
131
131
|
Maintainers use Users too, for Agent-only users and their own account only. They can create, edit, block
|
|
132
132
|
and delete those users and issue or revoke their keys, but can't create or promote a maintainer or admin.
|
|
133
133
|
They don't see admins or other maintainers. They also manage #[a(href="/admin/agents") CI tokens].
|
|
134
|
+
#[strong Recent activity] at the bottom of Users is the audit log: #[strong Timestamp | User | Action], where
|
|
135
|
+
User is who made the change (a username, #[code thub-admin (<OS user>)], or #[code system]).
|
|
134
136
|
+note('warning').
|
|
135
137
|
Forgot the admin password? Start the Coordinator once with #[code THUB_BOOTSTRAP_ADMIN_PASSWORD=…]
|
|
136
138
|
(and optionally #[code THUB_BOOTSTRAP_ADMIN_USER], default #[code admin]). The password is reset on
|