@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 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 = 'thub-admin',
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",
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
- const audit = (id, type, data) => events?.record('user', id, type, data);
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
- events: isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds),
1320
+ audit: auditRows(isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds)),
1270
1321
  ...extra
1271
1322
  });
1272
1323
  }
@@ -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(/&#39;/g, '\'').replace(/&gt;/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
+ });
@@ -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 events.length
200
- ul.list-group.list-group-flush.small.mb-4
201
- each e in events
202
- li.list-group-item.px-0
203
- span.text-body-secondary.me-2= fmtDate(e.ts)
204
- strong= e.type.replace('user.', '').replace(/_/g, ' ')
205
- if e.data.username
206
- = ` ${e.data.username}`
207
- if e.data.role && e.data.role.from
208
- = ` — ${roleLabel(e.data.role.from)} → ${roleLabel(e.data.role.to)}`
209
- else if e.data.role
210
- = ` (${roleLabel(e.data.role)})`
211
- if e.data.by
212
- span.text-body-secondary= ` by ${e.data.by}`
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 (&lt;OS user&gt;)], 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