@andrian.yablonskyy/thub-coordinator 1.0.43 → 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/js/actions.js +10 -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 +7 -7
- 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/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]'),
|
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 }),
|