@andrian.yablonskyy/thub-coordinator 1.0.42 → 1.1.0
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 +98 -7
- package/package.json +2 -2
- package/public/css/thub.css +108 -0
- package/public/js/actions.js +33 -0
- package/public/js/tooltips.js +5 -0
- package/src/api/agent.js +36 -9
- package/src/auth.js +39 -6
- package/src/db/migrations/026_users.sql +74 -0
- package/src/server.js +1 -1
- package/src/services/admin-users.js +291 -39
- package/src/services/agents.js +1 -1
- package/src/services/list-prefs.js +4 -0
- package/src/web/routes.js +213 -32
- package/test/admin-users.test.js +4 -4
- package/test/groups-tab.test.js +3 -3
- package/test/users.test.js +269 -0
- package/test/views.test.js +3 -1
- package/views/admin/agents.pug +7 -8
- package/views/admin/users.pug +249 -0
- package/views/help/_agent-cli.pug +4 -2
- package/views/help/_agent-setup.pug +28 -23
- package/views/help/_ci.pug +4 -4
- package/views/help/_coordinator.pug +4 -3
- package/views/help/_env.pug +1 -1
- package/views/help/_overview.pug +1 -1
- package/views/help/_quick-start.pug +5 -5
- package/views/help/_troubleshooting.pug +1 -0
- package/views/index.pug +1 -1
- package/views/layout.pug +19 -22
- package/views/mixins/client-config.pug +2 -2
- package/views/mixins/resource.pug +23 -21
- package/views/mixins/updates.pug +2 -2
- package/views/profile.pug +56 -0
- package/views/resources/list.pug +3 -3
package/bin/thub-admin.js
CHANGED
|
@@ -34,14 +34,24 @@ const { loadConfig } = require('../src/config'),
|
|
|
34
34
|
{ createCleanupService } = require('../src/services/cleanup'),
|
|
35
35
|
{ generateToken } = require('../src/services/tokens'),
|
|
36
36
|
{ createSettingsService } = require('../src/services/settings'),
|
|
37
|
+
{ roleLabel, normalizeRole } = require('../src/services/admin-users'),
|
|
37
38
|
{ PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
|
|
38
39
|
{ spawnSync } = require('node:child_process'),
|
|
39
40
|
{ version: installedVersion } = require('../package.json');
|
|
40
41
|
|
|
41
42
|
function usage(){
|
|
42
43
|
console.log(`Usage:
|
|
43
|
-
thub-admin
|
|
44
|
-
|
|
44
|
+
thub-admin user add <username> --email <email> [--role user|maintainer|admin] [--password <p>] [--key]
|
|
45
|
+
New user (§10.3). Maintainers/admins without --password get a
|
|
46
|
+
temporary one (printed once); --key also creates their access key
|
|
47
|
+
thub-admin user list
|
|
48
|
+
thub-admin user block|unblock <username>
|
|
49
|
+
thub-admin user role <username> user|maintainer|admin
|
|
50
|
+
thub-admin user password <username> <password> Set a password (no change required)
|
|
51
|
+
thub-admin user key <username> New access key for them (printed once; the old one stops)
|
|
52
|
+
thub-admin user delete <username>
|
|
53
|
+
thub-admin create-admin <username> <password> [--role admin|maintainer] (= user add, no email)
|
|
54
|
+
thub-admin agent add <name> A CI token (pipelines; people get their key with: user key)
|
|
45
55
|
thub-admin join-key generate
|
|
46
56
|
thub-admin resource maintenance <resourceId> --on|--off
|
|
47
57
|
thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
|
|
@@ -138,7 +148,7 @@ function main(){
|
|
|
138
148
|
events = createEventsService(db),
|
|
139
149
|
registry = createRegistryService(db, { bus, events }),
|
|
140
150
|
agents = createAgentsService(db, { events }),
|
|
141
|
-
adminUsers = createAdminUsersService(db),
|
|
151
|
+
adminUsers = createAdminUsersService(db, { events }),
|
|
142
152
|
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
143
153
|
groups = createGroupsService(db, { events, registry });
|
|
144
154
|
|
|
@@ -159,6 +169,83 @@ function main(){
|
|
|
159
169
|
return;
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
// Users (§10.3) — also the way back in when nobody can sign in: unblock or
|
|
173
|
+
// re-role an admin, set a password. Run as the Coordinator's user.
|
|
174
|
+
if (cmd === 'user'){
|
|
175
|
+
const { positional, flags } = parseFlags(rest),
|
|
176
|
+
[username, arg] = positional,
|
|
177
|
+
by = 'thub-admin',
|
|
178
|
+
need = (u) => {
|
|
179
|
+
const row = adminUsers.getByUsername(u || '');
|
|
180
|
+
if (!row){
|
|
181
|
+
console.error(`No user "${u}"`);
|
|
182
|
+
process.exit(4);
|
|
183
|
+
}
|
|
184
|
+
return row;
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
if (sub === 'list'){
|
|
188
|
+
for (const u of adminUsers.list()){
|
|
189
|
+
const key = u.key ? `…${u.key.hint || '????'}` : 'none';
|
|
190
|
+
console.log(`${u.username.padEnd(24)} ${roleLabel(u.role).padEnd(11)} ${u.blocked ? 'blocked' : 'active '} ${u.email || '(no email)'} key: ${key}`);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (sub === 'add'){
|
|
195
|
+
if (!username || !flags.email){
|
|
196
|
+
return usage(), process.exit(4);
|
|
197
|
+
}
|
|
198
|
+
const created = adminUsers.create({ username, email: flags.email, role: flags.role || 'user', password: flags.password, requireEmail: true }, { by });
|
|
199
|
+
console.log(`Created ${roleLabel(created.role)} "${created.username}"`);
|
|
200
|
+
if (created.tempPassword){
|
|
201
|
+
console.log(`Temporary password (shown once; to change at the first sign-in): ${created.tempPassword}`);
|
|
202
|
+
}
|
|
203
|
+
if (rest.includes('--key')){
|
|
204
|
+
console.log(`Access key (shown once): ${adminUsers.issueKey(created.id, { by })}`);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const target = need(username);
|
|
209
|
+
if (sub === 'block' || sub === 'unblock'){
|
|
210
|
+
adminUsers.setBlocked(target.id, sub === 'block', { by });
|
|
211
|
+
console.log(`${sub === 'block' ? 'Blocked' : 'Unblocked'} "${target.username}"`);
|
|
212
|
+
}
|
|
213
|
+
else if (sub === 'role'){
|
|
214
|
+
const { tempPassword } = adminUsers.update(target.id, { role: arg }, { by });
|
|
215
|
+
console.log(`"${target.username}" is now ${roleLabel(normalizeRole(arg))}`);
|
|
216
|
+
if (tempPassword){
|
|
217
|
+
console.log(`Temporary password (shown once): ${tempPassword}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (sub === 'password'){
|
|
221
|
+
if (!arg){
|
|
222
|
+
return usage(), process.exit(4);
|
|
223
|
+
}
|
|
224
|
+
adminUsers.resetPassword({ username: target.username, password: arg });
|
|
225
|
+
console.log(`Password of "${target.username}" set`);
|
|
226
|
+
}
|
|
227
|
+
else if (sub === 'key'){
|
|
228
|
+
console.log(`Access key for "${target.username}" (shown once; any previous one has stopped working): ${adminUsers.issueKey(target.id, { by })}`);
|
|
229
|
+
}
|
|
230
|
+
else if (sub === 'delete'){
|
|
231
|
+
if (!flags.yes){
|
|
232
|
+
console.error(`This deletes "${target.username}" for good (their jobs stay). Re-run with --yes to confirm.`);
|
|
233
|
+
process.exit(4);
|
|
234
|
+
}
|
|
235
|
+
adminUsers.remove(target.id, { by });
|
|
236
|
+
console.log(`Deleted "${target.username}"`);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
return usage(), process.exit(4);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err){
|
|
243
|
+
console.error(`Error: ${err.message}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
162
249
|
if (cmd === 'create-admin'){
|
|
163
250
|
const { positional, flags } = parseFlags([sub, ...rest].filter(Boolean)),
|
|
164
251
|
[username, password] = positional;
|
|
@@ -166,18 +253,22 @@ function main(){
|
|
|
166
253
|
return usage(), process.exit(4);
|
|
167
254
|
}
|
|
168
255
|
adminUsers.create({ username, password, role: flags.role || 'admin' });
|
|
169
|
-
console.log(`Created ${flags.role || 'admin'} user "${username}"`);
|
|
256
|
+
console.log(`Created ${roleLabel(normalizeRole(flags.role || 'admin'))} user "${username}"`);
|
|
170
257
|
return;
|
|
171
258
|
}
|
|
172
259
|
|
|
173
260
|
if (cmd === 'agent' && sub === 'add'){
|
|
174
261
|
const { positional, flags } = parseFlags(rest),
|
|
175
262
|
[name] = positional;
|
|
176
|
-
if (!name
|
|
263
|
+
if (!name){
|
|
177
264
|
return usage(), process.exit(4);
|
|
178
265
|
}
|
|
179
|
-
|
|
180
|
-
|
|
266
|
+
if (flags.kind && flags.kind !== 'ci'){
|
|
267
|
+
console.error('Agent tokens are for CI only now; a person gets their access key with: thub-admin user key <username>');
|
|
268
|
+
process.exit(4);
|
|
269
|
+
}
|
|
270
|
+
const { agent, token } = agents.create({ name, kind: 'ci' });
|
|
271
|
+
console.log(`CI token ${agent.id} created. Token (shown once): ${token}`);
|
|
181
272
|
return;
|
|
182
273
|
}
|
|
183
274
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lint:fix": "eslint . --fix"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@andrian.yablonskyy/thub-common": "^1.0
|
|
22
|
+
"@andrian.yablonskyy/thub-common": "^1.1.0",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
package/public/css/thub.css
CHANGED
|
@@ -227,3 +227,111 @@
|
|
|
227
227
|
animation: none;
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
|
|
231
|
+
/* Mobile / tablet menu (below lg, where the navbar collapses; layout.pug).
|
|
232
|
+
It drops down over the page instead of pushing it down, scrolls inside
|
|
233
|
+
itself on short screens, and is laid out for thumbs: pages as a two-column
|
|
234
|
+
grid, the tools in one row, the account in the next. public/js/actions.js
|
|
235
|
+
closes it on a tap outside, Esc, or choosing a page. */
|
|
236
|
+
.thub-nav-break {
|
|
237
|
+
display: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@media (max-width: 991.98px) {
|
|
241
|
+
.navbar .thub-nav {
|
|
242
|
+
position: absolute;
|
|
243
|
+
top: 100%;
|
|
244
|
+
left: 0;
|
|
245
|
+
right: 0;
|
|
246
|
+
z-index: 1035;
|
|
247
|
+
max-height: calc(100dvh - 4rem);
|
|
248
|
+
overflow-y: auto;
|
|
249
|
+
padding: 0.75rem 1rem 1rem;
|
|
250
|
+
background: var(--bs-tertiary-bg);
|
|
251
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
252
|
+
box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.15);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.thub-nav-pages {
|
|
256
|
+
display: grid;
|
|
257
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
258
|
+
gap: 0.375rem;
|
|
259
|
+
padding-bottom: 0.75rem;
|
|
260
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.thub-nav-pages .nav-link {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
min-height: 2.75rem;
|
|
267
|
+
padding: 0.5rem 0.75rem;
|
|
268
|
+
border-radius: var(--bs-border-radius);
|
|
269
|
+
background: var(--bs-body-bg);
|
|
270
|
+
border: 1px solid var(--bs-border-color-translucent);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.thub-nav-pages .nav-link.active {
|
|
274
|
+
background: var(--bs-primary-bg-subtle);
|
|
275
|
+
border-color: var(--bs-primary-border-subtle);
|
|
276
|
+
color: var(--bs-primary-text-emphasis);
|
|
277
|
+
font-weight: 500;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.thub-nav-tools {
|
|
281
|
+
flex-direction: row;
|
|
282
|
+
flex-wrap: wrap;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: 0.5rem 0.75rem;
|
|
285
|
+
padding-top: 0.75rem;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.thub-nav-tools > li {
|
|
289
|
+
margin: 0 !important;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* Bigger tap targets for the icon buttons. */
|
|
293
|
+
.thub-nav-tools .btn-sm {
|
|
294
|
+
min-width: 2.5rem;
|
|
295
|
+
min-height: 2.5rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* The account row: the profile link takes the line, sign-out at its end. */
|
|
299
|
+
.thub-nav-break {
|
|
300
|
+
display: block;
|
|
301
|
+
flex-basis: 100%;
|
|
302
|
+
height: 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.thub-nav-account {
|
|
306
|
+
flex: 1 1 auto;
|
|
307
|
+
min-height: 2.5rem;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* Phones: wide tables scroll sideways inside their own box instead of
|
|
312
|
+
running off the page, with one-line headers (wrapped headers of squeezed
|
|
313
|
+
columns used to make the header row very tall). The resource card's tab
|
|
314
|
+
row scrolls sideways too rather than wrapping onto several lines. */
|
|
315
|
+
@media (max-width: 767.98px) {
|
|
316
|
+
main .table:not(.table-responsive .table) {
|
|
317
|
+
display: block;
|
|
318
|
+
max-width: 100%;
|
|
319
|
+
overflow-x: auto;
|
|
320
|
+
-webkit-overflow-scrolling: touch;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
main .table th {
|
|
324
|
+
white-space: nowrap;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.thub-resource-card .nav-tabs {
|
|
328
|
+
flex-wrap: nowrap;
|
|
329
|
+
overflow-x: auto;
|
|
330
|
+
overflow-y: hidden;
|
|
331
|
+
scrollbar-width: thin;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.thub-resource-card .nav-tabs .nav-link {
|
|
335
|
+
white-space: nowrap;
|
|
336
|
+
}
|
|
337
|
+
}
|
package/public/js/actions.js
CHANGED
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
// <select data-describe="#hint">: show the chosen option's data-help in
|
|
34
|
+
// #hint (e.g. what a role allows, under the role picker).
|
|
35
|
+
document.addEventListener('change', (e) => {
|
|
36
|
+
const select = e.target.closest?.('select[data-describe]'),
|
|
37
|
+
hint = select && document.querySelector(select.dataset.describe);
|
|
38
|
+
if (hint){
|
|
39
|
+
hint.textContent = select.selectedOptions[0]?.dataset.help || '';
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
33
43
|
// <button data-fill="#field" data-value="…">: put the value into #field.
|
|
34
44
|
document.addEventListener('click', (e) => {
|
|
35
45
|
const btn = e.target.closest('[data-fill]'),
|
|
@@ -39,6 +49,29 @@
|
|
|
39
49
|
}
|
|
40
50
|
});
|
|
41
51
|
|
|
52
|
+
// The collapsed (mobile) menu drops down over the page (thub.css
|
|
53
|
+
// .thub-nav): close it on a tap outside, on Esc, and once a page in it is
|
|
54
|
+
// chosen — over the content, it shouldn't stay open in the way.
|
|
55
|
+
const nav = document.getElementById('nav'),
|
|
56
|
+
closeNav = () => {
|
|
57
|
+
if (nav?.classList.contains('show')){
|
|
58
|
+
window.bootstrap?.Collapse.getOrCreateInstance(nav, { toggle: false }).hide();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
document.addEventListener('click', (e) => {
|
|
62
|
+
if (!nav?.classList.contains('show')){
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!e.target.closest('#nav, .navbar-toggler') || e.target.closest('#nav a[href]')){
|
|
66
|
+
closeNav();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
document.addEventListener('keydown', (e) => {
|
|
70
|
+
if (e.key === 'Escape'){
|
|
71
|
+
closeNav();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
42
75
|
// <form data-submit-busy>: disable its button and show a spinner while
|
|
43
76
|
// the request runs (e.g. the navbar's check for updates).
|
|
44
77
|
document.addEventListener('submit', (e) => {
|
package/public/js/tooltips.js
CHANGED
|
@@ -16,5 +16,10 @@
|
|
|
16
16
|
// Delegated (one instance per element, created on first hover/focus), so
|
|
17
17
|
// content swapped in by live updates (public/js/live.js) gets tooltips too.
|
|
18
18
|
(function (){
|
|
19
|
+
// On touch screens a tap would open a tooltip and leave it stuck there
|
|
20
|
+
// (no hover to end it) — over the mobile menu, too: no tooltips there.
|
|
21
|
+
if (!window.matchMedia('(hover: hover)').matches){
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
19
24
|
new bootstrap.Tooltip(document.body, { selector: '[data-bs-toggle="tooltip"]' });
|
|
20
25
|
})();
|
package/src/api/agent.js
CHANGED
|
@@ -26,14 +26,17 @@ function createAgentRouter({ services, config }){
|
|
|
26
26
|
// matching even happens, and reject them for lacking an agent token.
|
|
27
27
|
auth = requireRole('agent'),
|
|
28
28
|
|
|
29
|
-
// Which jobs
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// doesn't exist (404), so a
|
|
33
|
-
|
|
29
|
+
// Which jobs a key may see (README §12, §10.3): a CI token all of them;
|
|
30
|
+
// a user's key all of them for maintainers and admins, only their own
|
|
31
|
+
// for the `user` role. Anyone else's job is answered exactly like a job
|
|
32
|
+
// that doesn't exist (404), so a user can't tell which ids are in use.
|
|
33
|
+
seesAll = (req) => req.agent.kind === 'ci' || ['maintainer', 'admin'].includes(req.user?.role),
|
|
34
|
+
canSee = (req, job) => seesAll(req) || job.agent_id === req.agent.id,
|
|
35
|
+
// Cancelling: maintainers and admins any job; anyone else their own.
|
|
36
|
+
cancelsAll = (req) => ['maintainer', 'admin'].includes(req.user?.role),
|
|
34
37
|
visibleJob = (req, res, next) => {
|
|
35
38
|
const job = services.jobs.get(req.params.id);
|
|
36
|
-
if (!job || !canSee(req
|
|
39
|
+
if (!job || !canSee(req, job)){
|
|
37
40
|
return res.status(404).json({ error: 'Unknown job' });
|
|
38
41
|
}
|
|
39
42
|
req.job = job;
|
|
@@ -68,8 +71,8 @@ function createAgentRouter({ services, config }){
|
|
|
68
71
|
state: req.query.state,
|
|
69
72
|
source: req.query.source,
|
|
70
73
|
agentId: req.agent.id,
|
|
71
|
-
// A
|
|
72
|
-
mine: req
|
|
74
|
+
// A `user`'s list is always their own jobs (canSee above).
|
|
75
|
+
mine: !seesAll(req) || req.query.mine === 'true' || req.query.mine === '1',
|
|
73
76
|
limit: req.query.limit ? Number(req.query.limit) : undefined
|
|
74
77
|
});
|
|
75
78
|
res.json({ jobs: jobs.map(services.jobs.publicJob) });
|
|
@@ -77,7 +80,7 @@ function createAgentRouter({ services, config }){
|
|
|
77
80
|
|
|
78
81
|
router.post('/jobs/:id/cancel', auth, visibleJob, (req, res, next) => {
|
|
79
82
|
try {
|
|
80
|
-
const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin:
|
|
83
|
+
const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: cancelsAll(req) });
|
|
81
84
|
res.json(services.jobs.publicJob(job));
|
|
82
85
|
}
|
|
83
86
|
catch (err){
|
|
@@ -101,6 +104,30 @@ function createAgentRouter({ services, config }){
|
|
|
101
104
|
res.json({ artifacts: req.job.artifacts.map((a) => ({ ...a, url: a.link })) });
|
|
102
105
|
});
|
|
103
106
|
|
|
107
|
+
// `thub whoami` / `thub key show` (§10.3): who this key belongs to.
|
|
108
|
+
router.get('/me', auth, (req, res) => {
|
|
109
|
+
if (!req.user){
|
|
110
|
+
return res.json({ kind: req.agent.kind, name: req.agent.name });
|
|
111
|
+
}
|
|
112
|
+
const { username, email, role } = req.user,
|
|
113
|
+
key = services.adminUsers.keyOf(req.user.id);
|
|
114
|
+
res.json({
|
|
115
|
+
kind: req.agent.kind,
|
|
116
|
+
user: { username, email, role },
|
|
117
|
+
key: { hint: key.token_hint, createdAt: key.token_created_at || key.created_at, lastUsedAt: key.last_used_at }
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// `thub key rotate`: a user replaces their own key — the one sending this
|
|
122
|
+
// stops working at once, the new one is in the answer (only there).
|
|
123
|
+
// A CI token is replaced by an admin, on the dashboard.
|
|
124
|
+
router.post('/me/key/rotate', auth, (req, res) => {
|
|
125
|
+
if (!req.user){
|
|
126
|
+
return res.status(403).json({ error: 'A CI token is replaced by an admin on the dashboard (CI tokens)' });
|
|
127
|
+
}
|
|
128
|
+
res.json({ key: services.adminUsers.issueKey(req.user.id, { by: req.user.username }), username: req.user.username });
|
|
129
|
+
});
|
|
130
|
+
|
|
104
131
|
router.get('/resources', auth, (req, res) => {
|
|
105
132
|
res.json({ resources: services.registry.list().map(publicResource) });
|
|
106
133
|
});
|
package/src/auth.js
CHANGED
|
@@ -36,11 +36,20 @@ function requireRole(role){
|
|
|
36
36
|
const tokenHash = hashToken(token);
|
|
37
37
|
|
|
38
38
|
if (role === 'agent'){
|
|
39
|
-
const
|
|
39
|
+
const { agents, adminUsers } = req.app.locals.services,
|
|
40
|
+
agent = agents.getByTokenHash(tokenHash);
|
|
40
41
|
if (!agent){
|
|
41
|
-
return res.status(401).json({ error: 'Invalid or revoked
|
|
42
|
+
return res.status(401).json({ error: 'Invalid or revoked access key' });
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
// A user's access key (§10.3) works only while its user may use it.
|
|
45
|
+
if (agent.user_id){
|
|
46
|
+
const user = adminUsers.getById(agent.user_id);
|
|
47
|
+
if (!user || user.blocked){
|
|
48
|
+
return res.status(401).json({ error: user ? `User ${user.username} is blocked — ask an admin` : 'Invalid or revoked access key' });
|
|
49
|
+
}
|
|
50
|
+
req.user = user;
|
|
51
|
+
}
|
|
52
|
+
agents.touchLastUsed(agent.id, appVersion(req, 'agent'));
|
|
44
53
|
req.agent = agent;
|
|
45
54
|
return next();
|
|
46
55
|
}
|
|
@@ -81,19 +90,43 @@ function requireJoinKey(config){
|
|
|
81
90
|
};
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
// Dashboard session (§10.3): the user is read again on every request, so
|
|
94
|
+
// blocking, deleting or demoting someone to `user` ends their session at
|
|
95
|
+
// their next click, and a role change applies at once. A temporary password
|
|
96
|
+
// must be changed (on the profile page) before anything else.
|
|
97
|
+
const MUST_CHANGE_ALLOWED = ['/profile', '/profile/password', '/logout', '/live'];
|
|
84
98
|
function requireAdminSession(req, res, next){
|
|
85
|
-
|
|
99
|
+
const fresh = req.session?.user && req.app.locals.services.adminUsers.getById(req.session.user.id);
|
|
100
|
+
if (!fresh || fresh.blocked || !fresh.canUseDashboard){
|
|
101
|
+
const reason = !req.session?.user ? null
|
|
102
|
+
: !fresh ? 'Your account no longer exists.'
|
|
103
|
+
: fresh.blocked ? 'Your account is blocked — ask an admin.' : 'Your account has no dashboard access — use the Agent with your access key.';
|
|
104
|
+
if (req.session?.user){
|
|
105
|
+
req.session.user = null;
|
|
106
|
+
if (reason){
|
|
107
|
+
req.session.flash = [{ type: 'danger', text: reason }];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
86
110
|
if (req.accepts('html')){
|
|
87
111
|
return res.redirect('/login');
|
|
88
112
|
}
|
|
89
|
-
return res.status(401).json({ error: 'Login required' });
|
|
113
|
+
return res.status(401).json({ error: reason || 'Login required' });
|
|
114
|
+
}
|
|
115
|
+
req.session.user = fresh;
|
|
116
|
+
res.locals.user = fresh;
|
|
117
|
+
if (fresh.mustChangePassword && !MUST_CHANGE_ALLOWED.includes(req.path) && req.method === 'GET'){
|
|
118
|
+
return res.redirect('/profile');
|
|
90
119
|
}
|
|
91
120
|
next();
|
|
92
121
|
}
|
|
93
122
|
|
|
123
|
+
// The dashboard's security features (§10.3): Users, Settings, CI tokens…
|
|
94
124
|
function requireAdminRole(req, res, next){
|
|
95
125
|
if (req.session?.user?.role !== 'admin'){
|
|
96
|
-
|
|
126
|
+
const text = 'Only admins can open this — it\'s one of the Coordinator\'s security features.';
|
|
127
|
+
return req.originalUrl.startsWith('/api/') || !req.accepts('html')
|
|
128
|
+
? res.status(403).json({ error: text })
|
|
129
|
+
: res.status(403).type('text/plain').send(text);
|
|
97
130
|
}
|
|
98
131
|
next();
|
|
99
132
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- User management (README §10.3): one account table for everyone. Roles:
|
|
2
|
+
-- user submits jobs with the Agent and reads its results; no dashboard
|
|
3
|
+
-- maintainer user + the dashboard, without its security features
|
|
4
|
+
-- admin everything
|
|
5
|
+
-- Each user may hold one access key: an `agents` row linked by user_id
|
|
6
|
+
-- (jobs already reference agents, so a user's job history stays attached
|
|
7
|
+
-- across key rotations). CI tokens stay agents rows without a user.
|
|
8
|
+
--
|
|
9
|
+
-- admin_users becomes users: SQLite can't change its role CHECK in place.
|
|
10
|
+
CREATE TABLE users (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
username TEXT NOT NULL UNIQUE,
|
|
13
|
+
email TEXT NOT NULL DEFAULT '',
|
|
14
|
+
-- NULL: no dashboard sign-in (role user, or a converted agent token).
|
|
15
|
+
password_hash TEXT,
|
|
16
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'maintainer', 'admin')),
|
|
17
|
+
first_name TEXT,
|
|
18
|
+
last_name TEXT,
|
|
19
|
+
avatar_path TEXT,
|
|
20
|
+
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
21
|
+
theme TEXT NOT NULL DEFAULT 'auto' CHECK (theme IN ('auto', 'light', 'dark')),
|
|
22
|
+
session_timeout_min INTEGER NOT NULL DEFAULT 60,
|
|
23
|
+
list_prefs TEXT,
|
|
24
|
+
must_change_password INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
blocked_at TEXT,
|
|
26
|
+
created_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- Dashboard accounts: admin stays admin; viewer becomes maintainer.
|
|
30
|
+
INSERT INTO users (id, username, email, password_hash, role, first_name, last_name, avatar_path, timezone, theme,
|
|
31
|
+
session_timeout_min, list_prefs, created_at)
|
|
32
|
+
SELECT id, username, '', password_hash, CASE role WHEN 'viewer' THEN 'maintainer' ELSE role END,
|
|
33
|
+
first_name, last_name, avatar_path, timezone, theme, session_timeout_min, list_prefs, created_at
|
|
34
|
+
FROM admin_users;
|
|
35
|
+
|
|
36
|
+
DROP TABLE admin_users;
|
|
37
|
+
|
|
38
|
+
-- Every developer (cli) agent token becomes a user that owns it, so each
|
|
39
|
+
-- existing key keeps working. Its name becomes the username — suffixed
|
|
40
|
+
-- where that's taken (a dashboard account, an earlier token of the same
|
|
41
|
+
-- name). A revoked token becomes a blocked user, kept for its job history.
|
|
42
|
+
INSERT INTO users (id, username, email, password_hash, role, blocked_at, created_at)
|
|
43
|
+
SELECT 'usr_' || substr(a.id, 5),
|
|
44
|
+
a.name || CASE
|
|
45
|
+
WHEN EXISTS (SELECT 1 FROM users u WHERE u.username = a.name)
|
|
46
|
+
OR EXISTS (SELECT 1 FROM agents b WHERE b.kind = 'cli' AND b.name = a.name AND (b.created_at < a.created_at OR (b.created_at = a.created_at AND b.id < a.id)))
|
|
47
|
+
THEN '-' || substr(a.id, 5, 6) ELSE '' END,
|
|
48
|
+
'', NULL, 'user', a.revoked_at, a.created_at
|
|
49
|
+
FROM agents a
|
|
50
|
+
WHERE a.kind = 'cli';
|
|
51
|
+
|
|
52
|
+
ALTER TABLE agents ADD COLUMN user_id TEXT REFERENCES users(id);
|
|
53
|
+
-- The last characters of the key, to recognize it by (keys are stored hashed).
|
|
54
|
+
ALTER TABLE agents ADD COLUMN token_hint TEXT;
|
|
55
|
+
ALTER TABLE agents ADD COLUMN token_created_at TEXT;
|
|
56
|
+
UPDATE agents SET user_id = 'usr_' || substr(id, 5) WHERE kind = 'cli';
|
|
57
|
+
|
|
58
|
+
CREATE UNIQUE INDEX idx_agents_user ON agents(user_id) WHERE user_id IS NOT NULL;
|
|
59
|
+
CREATE UNIQUE INDEX idx_users_email ON users(lower(email)) WHERE email <> '';
|
|
60
|
+
|
|
61
|
+
-- The audit log gets user events (created, blocked, role changed, key
|
|
62
|
+
-- rotated…). Same rebuild as 006: nothing has a foreign key into events.
|
|
63
|
+
CREATE TABLE events_new (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
ts TEXT NOT NULL,
|
|
66
|
+
entity TEXT NOT NULL CHECK (entity IN ('job', 'resource', 'agent', 'group', 'user')),
|
|
67
|
+
entity_id TEXT NOT NULL,
|
|
68
|
+
type TEXT NOT NULL,
|
|
69
|
+
data TEXT
|
|
70
|
+
);
|
|
71
|
+
INSERT INTO events_new (id, ts, entity, entity_id, type, data) SELECT id, ts, entity, entity_id, type, data FROM events;
|
|
72
|
+
DROP TABLE events;
|
|
73
|
+
ALTER TABLE events_new RENAME TO events;
|
|
74
|
+
CREATE INDEX idx_events_entity ON events(entity, entity_id);
|
package/src/server.js
CHANGED
|
@@ -68,7 +68,7 @@ function buildServices(config){
|
|
|
68
68
|
registry = createRegistryService(db, { bus, events }),
|
|
69
69
|
agents = createAgentsService(db, { events }),
|
|
70
70
|
groups = createGroupsService(db, { events, registry }),
|
|
71
|
-
adminUsers = createAdminUsersService(db),
|
|
71
|
+
adminUsers = createAdminUsersService(db, { events }),
|
|
72
72
|
commands = createCommandsService({ bus }),
|
|
73
73
|
logs = createLogsService(db, { bus }),
|
|
74
74
|
jobs = createJobsService(db, { bus, events, registry, config }),
|