frugal-iot-server 2.0.6 → 2.0.8

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/lib/grafana.js ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Reading history over HTTP, for Grafana.
3
+ *
4
+ * Grafana's Infinity data source can call any URL that returns JSON, so rather than copy readings
5
+ * into a second store this serves them straight from the CSV files under data/ - the same files,
6
+ * and the same loader approach, as GET /api/data - in the shape Infinity wants: an array of
7
+ * { time, value }. Every organization's complete history is available this way, which exporting to
8
+ * a search index (see the OpenSearch forwarder in frugal-iot-logger) is not unless it was switched on.
9
+ *
10
+ * GET /grafana/:org/health {status: "ok"} - for "Test" in Grafana
11
+ * GET /grafana/:org/topics[?project=&node=] what there is to chart
12
+ * GET /grafana/:org/data?topic=&from=&to=&maxPoints= [{time, value}, ...]
13
+ *
14
+ * These are authenticated by a bearer token, not a login: Grafana is a program asking on a timer,
15
+ * so there is no person and no session. A token is read-only, belongs to one organization, and
16
+ * acts as the user who made it: each request is answered with whatever READ permissions that user
17
+ * holds at that moment, so it shows only their projects and stops working when their access does.
18
+ * Anyone who can READ the organization can make their own:
19
+ *
20
+ * POST /grafana_token/:org {name} issue (or re-issue, which replaces) - the token is in the reply, once
21
+ * GET /grafana_token/:org list names and when last used (yours; all, for an org-wide administrator)
22
+ * POST /grafana_token/:org/revoke {name} yours; any, for an org-wide administrator
23
+ *
24
+ * See GRAFANA.md for setting up Grafana to use these.
25
+ */
26
+
27
+ import crypto from 'crypto';
28
+ import express, { Router } from 'express';
29
+ import { readdir, readFile } from 'fs/promises';
30
+ import { fileURLToPath } from 'url';
31
+ import { gunzip as gunzipCallback } from 'zlib';
32
+ import { promisify } from 'util';
33
+ import { hasPermissions } from './config-for-user.js';
34
+ import { join, resolve, sep } from 'path';
35
+ import { jsonOnly } from './admin-guards.js';
36
+
37
+ const gunzip = promisify(gunzipCallback);
38
+
39
+ const DASHBOARD_TEMPLATE = fileURLToPath(new URL('../extras/grafana/frugaliot-readings.json', import.meta.url));
40
+
41
+ const DAY_MS = 24 * 60 * 60 * 1000;
42
+ const DEFAULT_MAX_POINTS = 1000;
43
+ const HARD_MAX_POINTS = 5000;
44
+ // Reads of history at once: in all, and for any one organization - so one organization's Grafana
45
+ // cannot leave everyone else's being told to wait
46
+ const MAX_CONCURRENT_READS = 4;
47
+ const MAX_CONCURRENT_READS_PER_ORG = 2;
48
+ const LAST_USED_GRANULARITY_MS = 60 * 60 * 1000;
49
+ const TOKEN_PREFIX = 'fiot_'; // so a leaked one is recognisable, to people and to secret scanners
50
+
51
+ // ---- tokens ------------------------------------------------------------------------------------
52
+ const run = (db, sql, args) => new Promise((res, rej) => db.run(sql, args, function (e) { e ? rej(e) : res(this); }));
53
+ const get = (db, sql, args) => new Promise((res, rej) => db.get(sql, args, (e, r) => (e ? rej(e) : res(r))));
54
+ const all = (db, sql, args) => new Promise((res, rej) => db.all(sql, args, (e, r) => (e ? rej(e) : res(r))));
55
+
56
+ export function hashToken(token) {
57
+ return crypto.createHash('sha256').update(token).digest('hex');
58
+ }
59
+
60
+ // Per person within an organization: tokens are the person's own, so the limit is theirs too
61
+ export const MAX_TOKENS_PER_USER = 10;
62
+
63
+ // A token belongs to the user who made it and is only ever as powerful as that user's READ permissions
64
+ // are at the moment it is used (see tokenUser) - so it neither outlives their access nor shows them
65
+ // anything the dashboard would not.
66
+ //
67
+ // Returns the new token - the only time it is ever available. Issuing a name you already have
68
+ // replaces it; a name another user holds in this organization is refused. The limit is part of the
69
+ // insert so that requests arriving together cannot all get under it.
70
+ export async function issueToken(db, org, name, userId) {
71
+ const token = TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
72
+ const existing = await get(db, 'SELECT user_id FROM grafana_tokens WHERE org = ? AND name = ?', [org, name]);
73
+ if (existing && existing.user_id !== userId) throw new TokenNameTaken(`The name ${JSON.stringify(name)} is used by someone else - choose another`);
74
+ const r = await run(db,
75
+ `INSERT INTO grafana_tokens (org, name, token_hash, created_at, user_id)
76
+ SELECT ?, ?, ?, ?, ?
77
+ WHERE (SELECT COUNT(*) FROM grafana_tokens WHERE org = ? AND user_id = ?) < ?
78
+ OR EXISTS (SELECT 1 FROM grafana_tokens WHERE org = ? AND name = ? AND user_id = ?)
79
+ ON CONFLICT(org, name) DO UPDATE SET token_hash = excluded.token_hash,
80
+ created_at = excluded.created_at, last_used = NULL
81
+ WHERE grafana_tokens.user_id = excluded.user_id`,
82
+ [org, name, hashToken(token), Date.now(), userId, org, userId, MAX_TOKENS_PER_USER, org, name, userId]);
83
+ if (!r.changes) throw new TooManyTokens(`You can have up to ${MAX_TOKENS_PER_USER} tokens in an organization - revoke one first`);
84
+ return token;
85
+ }
86
+ export class TooManyTokens extends Error {}
87
+ export class TokenNameTaken extends Error {}
88
+
89
+ // userId given: that user's tokens. Omitted: the whole organization's, for its administrator.
90
+ export function listTokens(db, org, userId) {
91
+ return userId === undefined
92
+ ? all(db, 'SELECT name, created_at, last_used, user_id FROM grafana_tokens WHERE org = ? ORDER BY name', [org])
93
+ : all(db, 'SELECT name, created_at, last_used, user_id FROM grafana_tokens WHERE org = ? AND user_id = ? ORDER BY name', [org, userId]);
94
+ }
95
+
96
+ // True if there was one to revoke. userId given: only that user's; omitted: any in the organization.
97
+ export async function revokeToken(db, org, name, userId) {
98
+ const r = userId === undefined
99
+ ? await run(db, 'DELETE FROM grafana_tokens WHERE org = ? AND name = ?', [org, name])
100
+ : await run(db, 'DELETE FROM grafana_tokens WHERE org = ? AND name = ? AND user_id = ?', [org, name, userId]);
101
+ return r.changes > 0;
102
+ }
103
+
104
+ // Which { org, name, userId } a token belongs to, or undefined. A token with no user (made before
105
+ // tokens were tied to one) is not honoured: it would be a pass for the whole organization that
106
+ // nobody's permissions stand behind.
107
+ export async function tokenOwner(db, token) {
108
+ if (!token) return undefined;
109
+ const row = await get(db, 'SELECT org, name, last_used, user_id FROM grafana_tokens WHERE token_hash = ?', [hashToken(token)]);
110
+ if (!row || row.user_id === null || row.user_id === undefined) return undefined;
111
+ if (!row.last_used || Date.now() - row.last_used > LAST_USED_GRANULARITY_MS) {
112
+ run(db, 'UPDATE grafana_tokens SET last_used = ? WHERE org = ? AND name = ?', [Date.now(), row.org, row.name])
113
+ .catch(() => {}); // only a convenience for the admin, never worth failing a request over
114
+ }
115
+ return { org: row.org, name: row.name, userId: row.user_id };
116
+ }
117
+
118
+ // The user a token acts as, with the permissions they hold NOW - read from the table on each request,
119
+ // not from any session, because a token is used with no session at all. A user who has been deleted
120
+ // is left with only what is granted to everyone (id 0), which is as little as can be.
121
+ export async function permissionsOf(db, userId) {
122
+ const permissions = await all(db, 'SELECT * FROM permissions WHERE id = ? OR id = 0', [userId]);
123
+ return { id: userId, permissions };
124
+ }
125
+
126
+ // ---- reading the files -------------------------------------------------------------------------
127
+ // A topic arrives from the URL, so it is held to what a real one can be before it goes anywhere near
128
+ // a file path: each level a plain name, and no "." or ".." to climb out of data/.
129
+ export function validTopic(topic, org) {
130
+ if (typeof topic !== 'string') return false;
131
+ const levels = topic.split('/');
132
+ return levels.length >= 5
133
+ && levels[0] === org
134
+ && levels.every((l) => /^[A-Za-z0-9_.+-]+$/.test(l) && l !== '.' && l !== '..');
135
+ }
136
+
137
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.csv(\.gz)?$/;
138
+
139
+ // Every reading of one topic in the range, a file at a time: { t: ms since 1970, value: number }.
140
+ // Not in time order across files unless the file names sort that way, which they do (dates).
141
+ async function* readTopic(topic, dataDir, fromMs, toMs) {
142
+ const dir = join(dataDir, ...topic.split('/'));
143
+ let files;
144
+ try { files = await readdir(dir); } catch { return; }
145
+ for (const file of files.sort()) {
146
+ const m = DAY_FILE.exec(file);
147
+ if (!m) continue;
148
+ const dayStart = Date.parse(`${m[1]}T00:00:00Z`);
149
+ if (dayStart + DAY_MS <= fromMs || dayStart > toMs) continue;
150
+ let text;
151
+ try {
152
+ const raw = await readFile(join(dir, file));
153
+ text = (m[2] ? await gunzip(raw) : raw).toString('utf8');
154
+ } catch (err) {
155
+ console.warn(`Grafana: could not read ${join(dir, file)}:`, err.message);
156
+ continue;
157
+ }
158
+ for (const line of text.split('\n')) {
159
+ const lm = /^(\d+),"(.*)"$/.exec(line);
160
+ if (!lm || lm[2] === '') continue;
161
+ const value = Number(lm[2]);
162
+ if (Number.isNaN(value)) continue;
163
+ const t = Number(lm[1]);
164
+ if (t >= fromMs && t <= toMs) yield { t, value };
165
+ }
166
+ }
167
+ }
168
+
169
+ // Every reading of one topic between two times: [{ t, value }], oldest first. Lines are
170
+ // 1703318400000,"25.3" - a reading that is not a number (a description, a colour) is left out, as
171
+ // it cannot be charted. Days gone by may be compressed (".csv.gz") by housekeeping.
172
+ export async function loadTopicData(topic, dataDir, fromMs, toMs) {
173
+ const rows = [];
174
+ for await (const r of readTopic(topic, dataDir, fromMs, toMs)) rows.push(r);
175
+ rows.sort((a, b) => a.t - b.t);
176
+ return rows;
177
+ }
178
+
179
+ // The same readings, ready to send: the series for a chart of at most maxPoints points. Unlike
180
+ // loadTopicData this never holds a long history in memory - past RAW_CAP readings it switches to
181
+ // adding each one into its time bucket as it is read, so a year of readings every few seconds costs
182
+ // as much memory as a day's. (The server may be a Raspberry Pi, and any holder of a token can ask for
183
+ // any range.) The answer is the same as downsample(loadTopicData(...)) either way.
184
+ const RAW_CAP = 20000;
185
+ export async function loadTopicSeries(topic, dataDir, fromMs, toMs, maxPoints) {
186
+ const width = Math.ceil((toMs - fromMs + 1) / maxPoints);
187
+ let rows = [];
188
+ let buckets = null; // Map of bucket -> { sum, n }, once there are too many to keep as they are
189
+ const add = (t, value) => {
190
+ const k = Math.floor((t - fromMs) / width);
191
+ const b = buckets.get(k);
192
+ if (b) { b.sum += value; b.n++; } else { buckets.set(k, { sum: value, n: 1 }); }
193
+ };
194
+ for await (const { t, value } of readTopic(topic, dataDir, fromMs, toMs)) {
195
+ if (buckets) { add(t, value); continue; }
196
+ rows.push({ t, value });
197
+ if (rows.length > RAW_CAP) {
198
+ buckets = new Map();
199
+ for (const r of rows) add(r.t, r.value);
200
+ rows = null;
201
+ }
202
+ }
203
+ if (!buckets) {
204
+ rows.sort((a, b) => a.t - b.t);
205
+ return downsample(rows, fromMs, toMs, maxPoints);
206
+ }
207
+ return [...buckets.entries()].sort((a, b) => a[0] - b[0])
208
+ .map(([k, b]) => ({ t: fromMs + k * width, value: b.sum / b.n }));
209
+ }
210
+
211
+ // Grafana asks for as many points as the panel has pixels. Past maxPoints, average into equal
212
+ // time buckets so a year of readings every ten seconds does not travel as three million rows.
213
+ export function downsample(rows, fromMs, toMs, maxPoints) {
214
+ if (rows.length <= maxPoints) return rows;
215
+ const width = Math.ceil((toMs - fromMs + 1) / maxPoints);
216
+ const buckets = new Map();
217
+ for (const { t, value } of rows) {
218
+ const k = Math.floor((t - fromMs) / width);
219
+ const b = buckets.get(k);
220
+ if (b) { b.sum += value; b.n++; } else { buckets.set(k, { sum: value, n: 1 }); }
221
+ }
222
+ return [...buckets.entries()].sort((a, b) => a[0] - b[0])
223
+ .map(([k, b]) => ({ t: fromMs + k * width, value: b.sum / b.n }));
224
+ }
225
+
226
+ // Every topic under an organization that has readings: a directory holding day files.
227
+ const topicCache = new Map(); // org -> { at, topics }
228
+ const TOPIC_CACHE_MS = 60 * 1000;
229
+ export function clearTopicCache() { topicCache.clear(); }
230
+ // Cached for a minute: it walks every directory of the organization, which is slow on a big one, and
231
+ // a new topic appearing a minute late in a dropdown is no loss.
232
+ export async function listTopics(org, dataDir) {
233
+ const hit = topicCache.get(`${dataDir}\0${org}`);
234
+ if (hit && Date.now() - hit.at < TOPIC_CACHE_MS) return hit.topics;
235
+ const topics = await walkTopics(org, dataDir);
236
+ topicCache.set(`${dataDir}\0${org}`, { at: Date.now(), topics });
237
+ return topics;
238
+ }
239
+ async function walkTopics(org, dataDir) {
240
+ const root = resolve(dataDir);
241
+ const topics = [];
242
+ async function walk(dir, levels) {
243
+ let entries;
244
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
245
+ if (entries.some((e) => e.isFile() && DAY_FILE.test(e.name))) {
246
+ const [o, project, node, module, ...rest] = levels;
247
+ if (rest.length > 0) topics.push({ topic: levels.join('/'), project, node, module, leaf: rest.join('/') });
248
+ }
249
+ if (levels.length >= 8) return; // nothing real is this deep
250
+ for (const e of entries) if (e.isDirectory()) await walk(join(dir, e.name), [...levels, e.name]);
251
+ }
252
+ const orgDir = resolve(root, org);
253
+ if (orgDir.startsWith(root + sep)) await walk(orgDir, [org]);
254
+ return topics;
255
+ }
256
+
257
+ // "1703318400000" (ms), "1703318400" (seconds) or an ISO date; undefined if it is none of those.
258
+ export function parseTime(v) {
259
+ if (v === undefined || v === '') return undefined;
260
+ if (/^\d+$/.test(String(v))) { const n = Number(v); return n < 1e11 ? n * 1000 : n; }
261
+ const t = Date.parse(v);
262
+ return Number.isNaN(t) ? undefined : t;
263
+ }
264
+
265
+ // The starter dashboard with this server and organization already filled in as the defaults Grafana
266
+ // offers on import, so all that is left to choose is the data source. `server` is the address Grafana
267
+ // reaches this server at; the dashboard's own browser knows it better than this process does, behind
268
+ // whatever proxy there is, so it is passed in. Only an http(s) origin is accepted.
269
+ export async function dashboardFor(org, server) {
270
+ let origin;
271
+ try {
272
+ const u = new URL(server);
273
+ if (!['http:', 'https:'].includes(u.protocol)) throw new Error();
274
+ origin = u.origin;
275
+ } catch { return undefined; }
276
+ const dash = JSON.parse(await readFile(DASHBOARD_TEMPLATE, 'utf8'));
277
+ for (const input of dash.__inputs) {
278
+ if (input.name === 'VAR_SERVER') input.value = origin;
279
+ if (input.name === 'VAR_ORG') input.value = org;
280
+ }
281
+ dash.title = `Frugal IoT readings - ${org}`;
282
+ dash.uid = `frugaliot-readings-${org}`; // a dashboard per organization, so importing a second does not replace the first
283
+ return dash;
284
+ }
285
+
286
+ // ---- the routes --------------------------------------------------------------------------------
287
+ /**
288
+ * @param db sqlite3 database
289
+ * @param dataDir where the CSV files are
290
+ * @param loggerClient has flush(), to get readings still in the logger's memory onto disk first
291
+ * @param auth { loggedInOrFail, can_READ } from frugal-iot-server.js, passed in so tests can stand in
292
+ * @returns { data, admin } - routers to mount at /grafana and /grafana_token
293
+ */
294
+ export function createGrafanaRouters(db, dataDir, loggerClient, auth) {
295
+ const data = Router();
296
+ let reading = 0; // reads of history in progress, across every token
297
+ const readingByOrg = new Map();
298
+ const admin = Router();
299
+
300
+ // Bearer token, and it must be for the organization in the URL, and its user must still be able to
301
+ // READ that organization. The same answer for an unknown token as for another organization's, so a
302
+ // token cannot be used to find out which exist. req.tokenUser is who it acts as.
303
+ const tokenAuth = async (req, res, next) => {
304
+ try {
305
+ const header = req.get('Authorization') || '';
306
+ const owner = await tokenOwner(db, header.startsWith('Bearer ') ? header.slice(7) : '');
307
+ if (owner && owner.org === req.params.org) {
308
+ const user = await permissionsOf(db, owner.userId);
309
+ if (hasPermissions(user, owner.org, 'READ')) {
310
+ req.tokenUser = user;
311
+ return next();
312
+ }
313
+ }
314
+ return res.status(403).json({ error: 'Not a valid token for this organization' });
315
+ } catch (err) { next(err); }
316
+ };
317
+
318
+ data.get('/:org/health', tokenAuth, (req, res) => res.json({ status: 'ok' }));
319
+
320
+ data.get('/:org/topics', tokenAuth, async (req, res, next) => {
321
+ try {
322
+ const { project, node } = req.query;
323
+ const topics = (await listTopics(req.params.org, dataDir))
324
+ .filter((t) => hasPermissions(req.tokenUser, req.params.org, 'READ', t.project))
325
+ .filter((t) => (!project || t.project === project) && (!node || t.node === node));
326
+ res.json(topics);
327
+ } catch (err) { next(err); }
328
+ });
329
+
330
+ data.get('/:org/data', tokenAuth, async (req, res, next) => {
331
+ try {
332
+ const { topic } = req.query;
333
+ if (!validTopic(topic, req.params.org)) {
334
+ return res.status(400).json({ error: `topic must be ${req.params.org}/project/node/module/leaf` });
335
+ }
336
+ if (!hasPermissions(req.tokenUser, req.params.org, 'READ', topic.split('/')[1])) {
337
+ return res.status(403).json({ error: 'Not a valid token for this project' });
338
+ }
339
+ const toMs = parseTime(req.query.to) ?? Date.now();
340
+ const fromMs = parseTime(req.query.from) ?? toMs - DAY_MS;
341
+ if (req.query.to !== undefined && parseTime(req.query.to) === undefined
342
+ || req.query.from !== undefined && parseTime(req.query.from) === undefined) {
343
+ return res.status(400).json({ error: 'from and to must be epoch milliseconds or ISO dates' });
344
+ }
345
+ if (fromMs > toMs) return res.status(400).json({ error: 'from is after to' });
346
+ const maxPoints = Math.min(Math.max(parseInt(req.query.maxPoints, 10) || DEFAULT_MAX_POINTS, 1), HARD_MAX_POINTS);
347
+ // Readings are held in memory and written out every few minutes, so write them first or a
348
+ // chart reaching "now" would stop short
349
+ // Reading a long history is real work, and a token can ask for as many at once as it likes.
350
+ // Past a few at a time, say so and let the caller retry - Grafana does - rather than let a
351
+ // small machine fall over.
352
+ const org = req.params.org;
353
+ if (reading >= MAX_CONCURRENT_READS || (readingByOrg.get(org) || 0) >= MAX_CONCURRENT_READS_PER_ORG) {
354
+ res.set('Retry-After', '5');
355
+ return res.status(503).json({ error: 'Busy - try again in a few seconds' });
356
+ }
357
+ reading++;
358
+ readingByOrg.set(org, (readingByOrg.get(org) || 0) + 1);
359
+ try {
360
+ await loggerClient.flush();
361
+ const rows = await loadTopicSeries(topic, dataDir, fromMs, toMs, maxPoints);
362
+ res.json(rows.map((r) => ({ time: new Date(r.t).toISOString(), value: r.value })));
363
+ } finally {
364
+ reading--;
365
+ readingByOrg.set(org, readingByOrg.get(org) - 1);
366
+ }
367
+ } catch (err) { next(err); }
368
+ });
369
+
370
+ // Whether this user is an administrator of the whole organization, who may see and revoke anyone's
371
+ // tokens; anyone else deals only with their own.
372
+ const isOrgAdmin = (req) => hasPermissions(req.user, req.params.org, 'ADMIN') && req.user.permissions.some(
373
+ (p) => p.capability === 'ADMIN' && p.org === req.params.org && !p.project);
374
+ const bad = (res, status, message) => res.status(status).json({ error: message, message });
375
+
376
+ // Issuing is POST, not GET: a GET could be triggered against a logged-in user by any page that can
377
+ // make their browser fetch a URL.
378
+ admin.post('/:org', auth.loggedInOrFail, auth.can_READ, jsonOnly, express.json({ limit: '1kb' }), async (req, res, next) => {
379
+ try {
380
+ const name = String((req.body && req.body.name) || '').trim();
381
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,39}$/.test(name)) {
382
+ return bad(res, 400, 'name must be 1-40 characters: letters, digits, space, _ . -');
383
+ }
384
+ let token;
385
+ try { token = await issueToken(db, req.params.org, name, Number(req.user.id)); }
386
+ catch (err) {
387
+ if (err instanceof TooManyTokens || err instanceof TokenNameTaken) return bad(res, 400, err.message);
388
+ throw err;
389
+ }
390
+ console.log('Grafana token issued by', req.user.username, '-', req.params.org, JSON.stringify(name));
391
+ res.set('Cache-Control', 'no-store'); // the token is in this reply and must not be kept anywhere
392
+ res.status(201).json({ org: req.params.org, name, token });
393
+ } catch (err) { next(err); }
394
+ });
395
+ admin.get('/:org', auth.loggedInOrFail, auth.can_READ, async (req, res, next) => {
396
+ try {
397
+ const rows = await listTokens(db, req.params.org, isOrgAdmin(req) ? undefined : Number(req.user.id));
398
+ res.json(rows.map(({ user_id, ...t }) => ({ ...t, mine: user_id === Number(req.user.id) })));
399
+ } catch (err) { next(err); }
400
+ });
401
+ // The starter dashboard, to save and import into Grafana (GRAFANA.md). Nothing in it is secret.
402
+ admin.get('/:org/dashboard', auth.loggedInOrFail, auth.can_READ, async (req, res, next) => {
403
+ try {
404
+ const dash = await dashboardFor(req.params.org, String(req.query.server || ''));
405
+ if (!dash) return bad(res, 400, 'server must be this server\'s http or https address');
406
+ res.set('Content-Disposition', `attachment; filename="frugaliot-${req.params.org}-dashboard.json"`);
407
+ res.json(dash);
408
+ } catch (err) { next(err); }
409
+ });
410
+ admin.post('/:org/revoke', auth.loggedInOrFail, auth.can_READ, jsonOnly, express.json({ limit: '1kb' }), async (req, res, next) => {
411
+ try {
412
+ const done = await revokeToken(db, req.params.org, String((req.body && req.body.name) || ''),
413
+ isOrgAdmin(req) ? undefined : Number(req.user.id));
414
+ console.log('Grafana token revoked by', req.user.username, '-', req.params.org, JSON.stringify(req.body && req.body.name));
415
+ res.status(done ? 200 : 404).json({ revoked: done });
416
+ } catch (err) { next(err); }
417
+ });
418
+
419
+ return { data, admin };
420
+ }
@@ -0,0 +1,245 @@
1
+ /*
2
+ * A login to this server's OpenSearch for a person, to put in OpenSearch Dashboards running on their own
3
+ * computer.
4
+ *
5
+ * OpenSearch Dashboards is a separate program that talks to OpenSearch on a person's behalf. It has
6
+ * ONE login in its own configuration file, and uses it both to read the data and to keep its own saved
7
+ * dashboards and index patterns - in an index inside OpenSearch (".kibana" unless told otherwise).
8
+ * Left alone that is a powerful, shared login. So:
9
+ *
10
+ * a user frugaliot_<org>_<username> one per person per organization, its own password, shown once
11
+ * a role frugaliot_<org> shared by that organization's users: may READ the
12
+ * organization's readings (frugaliot-<org>-*) and do anything
13
+ * to the organization's saved-objects index (.kibana_<org>*),
14
+ * and nothing else
15
+ * a mapping from the role's name as a "backend role" of each user - so adding or removing a person
16
+ * never rewrites a list another request might be changing
17
+ *
18
+ * and a line for Dashboards' config pointing it at that index (opensearchDashboards.index). The people
19
+ * of an organization share its saved dashboards; two organizations' Dashboards do not overwrite each
20
+ * other's.
21
+ *
22
+ * The role covers the WHOLE organization, so only a person who can READ the whole organization may have
23
+ * one (not someone limited to a project) - the others use Grafana, whose tokens follow project
24
+ * permissions. OpenSearch does not look at this server's permissions afterwards: a login stays until it
25
+ * is removed, by its owner or an organization administrator.
26
+ *
27
+ * Made through OpenSearch's security REST API, with the credentials of an account that is allowed to
28
+ * (config.d/opensearch.yaml, "admin"). It needs the security plugin: the Homebrew build has none, so
29
+ * this can only be tried on a real server.
30
+ *
31
+ * Only organizations whose id is lower-case letters and digits qualify - that is what keeps the index
32
+ * patterns exact, and the first "_" after the organization the start of the username - and only
33
+ * usernames of letters, digits, "." "_" and "-".
34
+ *
35
+ * GET /opensearch_access/:org { available, exists, username, hosts, index, all? }
36
+ * POST /opensearch_access/:org make your login, or replace its password - shown once
37
+ * POST /opensearch_access/:org/revoke {username?} remove yours; an organization administrator may name another's
38
+ */
39
+
40
+ import crypto from 'crypto';
41
+ import http from 'http';
42
+ import https from 'https';
43
+ import express, { Router } from 'express';
44
+ import { jsonOnly } from './admin-guards.js';
45
+
46
+ const ORG_OK = /^[a-z0-9]{1,20}$/;
47
+ const USERNAME_OK = /^[A-Za-z0-9._-]{1,40}$/;
48
+ const TIMEOUT_MS = 15000;
49
+
50
+ export const namesFor = (org, username) => ({
51
+ user: `frugaliot_${org}_${username}`,
52
+ role: `frugaliot_${org}`,
53
+ dataPattern: `frugaliot-${org}-*`,
54
+ savedIndex: `.kibana_${org}`,
55
+ });
56
+
57
+ // The role. Read on the readings; everything on this organization's own saved-objects index and its
58
+ // numbered versions. The cluster actions are those Dashboards needs to start and to bulk-write; each
59
+ // bulk item is still checked against the index permissions, so they do not open other indices.
60
+ export function roleFor(org) {
61
+ const n = namesFor(org);
62
+ return {
63
+ cluster_permissions: ['cluster_composite_ops', 'cluster:monitor/main', 'cluster:monitor/health'],
64
+ index_permissions: [
65
+ { index_patterns: [n.dataPattern],
66
+ allowed_actions: ['read', 'indices:admin/get', 'indices:admin/mappings/get',
67
+ 'indices:admin/resolve/index', 'indices:monitor/settings/get'] },
68
+ { index_patterns: [n.savedIndex, `${n.savedIndex}_*`], allowed_actions: ['indices_all'] },
69
+ ],
70
+ };
71
+ }
72
+
73
+ // One request to OpenSearch, as the account that may manage logins. https.request rather than fetch,
74
+ // because a server's own certificate often is not one a browser would accept and the operator may have
75
+ // said to allow that (config.d/opensearch.yaml "insecure"). Resolves { status, body }.
76
+ export function osRequest(cfg, method, path, body) {
77
+ const admin = cfg.admin || cfg;
78
+ const url = new URL(path, cfg.node.endsWith('/') ? cfg.node : cfg.node + '/');
79
+ const lib = url.protocol === 'https:' ? https : http;
80
+ const payload = body === undefined ? undefined : JSON.stringify(body);
81
+ const headers = { 'Content-Type': 'application/json' };
82
+ if (admin.username) headers.Authorization = 'Basic ' + Buffer.from(`${admin.username}:${admin.password || ''}`).toString('base64');
83
+ if (payload !== undefined) headers['Content-Length'] = Buffer.byteLength(payload);
84
+ return new Promise((resolve, reject) => {
85
+ const req = lib.request(url, { method, headers, rejectUnauthorized: !cfg.insecure, timeout: TIMEOUT_MS }, (res) => {
86
+ let text = '';
87
+ res.on('data', (c) => { text += c; });
88
+ res.on('end', () => {
89
+ let parsed;
90
+ try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = undefined; }
91
+ resolve({ status: res.statusCode, body: parsed });
92
+ });
93
+ });
94
+ req.on('timeout', () => req.destroy(new Error('OpenSearch did not answer')));
95
+ req.on('error', reject);
96
+ if (payload !== undefined) req.write(payload);
97
+ req.end();
98
+ });
99
+ }
100
+
101
+ const API = '_plugins/_security/api';
102
+ const ok = (r) => r.status >= 200 && r.status < 300;
103
+ const enc = encodeURIComponent;
104
+
105
+ export class AccessError extends Error {}
106
+
107
+ function need(r, what) {
108
+ if (!ok(r)) {
109
+ const why = r.status === 401 || r.status === 403 ? 'the account in config.d/opensearch.yaml is not allowed to manage logins'
110
+ : `OpenSearch said ${r.status}`;
111
+ throw new AccessError(`Could not ${what}: ${why}`);
112
+ }
113
+ }
114
+
115
+ export async function accessExists(cfg, org, username) {
116
+ const r = await osRequest(cfg, 'GET', `${API}/internalusers/${enc(namesFor(org, username).user)}`);
117
+ if (r.status === 404) return false;
118
+ need(r, 'check for the login');
119
+ return true;
120
+ }
121
+
122
+ // The usernames that have a login for this organization
123
+ export async function listAccess(cfg, org) {
124
+ const r = await osRequest(cfg, 'GET', `${API}/internalusers`);
125
+ need(r, 'list the logins');
126
+ const prefix = `frugaliot_${org}_`;
127
+ return Object.keys(r.body || {}).filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length)).sort();
128
+ }
129
+
130
+ // Makes the organization's role if it is not there (always rewritten, so a change to roleFor reaches
131
+ // it), and this person's login - or gives the existing one a new password. Returns { username, password, index }.
132
+ export async function createAccess(cfg, org, username) {
133
+ const n = namesFor(org, username);
134
+ const password = crypto.randomBytes(24).toString('base64url');
135
+ need(await osRequest(cfg, 'PUT', `${API}/roles/${enc(n.role)}`, roleFor(org)), 'make the role');
136
+ need(await osRequest(cfg, 'PUT', `${API}/rolesmapping/${enc(n.role)}`, { backend_roles: [n.role] }), 'connect the role');
137
+ need(await osRequest(cfg, 'PUT', `${API}/internalusers/${enc(n.user)}`,
138
+ { password, backend_roles: [n.role], attributes: { frugaliot_org: org, frugaliot_user: username } }), 'make the login');
139
+ return { username: n.user, password, index: n.savedIndex };
140
+ }
141
+
142
+ // Removes the person's login. The role stays: it grants nothing without a user, and the next person
143
+ // needs it.
144
+ export async function revokeAccess(cfg, org, username) {
145
+ const r = await osRequest(cfg, 'DELETE', `${API}/internalusers/${enc(namesFor(org, username).user)}`);
146
+ if (r.status === 404) return false;
147
+ need(r, 'remove the login');
148
+ return true;
149
+ }
150
+
151
+ // What an administrator copies into opensearch_dashboards.yml
152
+ export function dashboardsConfig(cfg, org, username, password) {
153
+ const n = namesFor(org, username);
154
+ return [
155
+ `opensearch.hosts: [ ${JSON.stringify(cfg.public)} ]`,
156
+ `opensearch.username: ${JSON.stringify(n.user)}`,
157
+ `opensearch.password: ${JSON.stringify(password)}`,
158
+ `opensearchDashboards.index: ${JSON.stringify(n.savedIndex)}`,
159
+ 'opensearch.requestHeadersAllowlist: [ authorization ]',
160
+ ].join('\n');
161
+ }
162
+
163
+ /**
164
+ * @param orgs () => the organizations configuration
165
+ * @param auth { loggedInOrFail, can_READ }
166
+ * @param localOpenSearch () => config.d/opensearch.yaml: { node, admin: {username, password}, insecure, public }
167
+ */
168
+ export function createAccessRouter(orgs, auth, localOpenSearch = () => undefined) {
169
+ const router = Router();
170
+ const bad = (res, status, message) => res.status(status).json({ error: message, message });
171
+ const has = (req, capability) => req.user.permissions.some(
172
+ (p) => p.capability === capability && p.org === req.params.org && !p.project);
173
+ const guard = [auth.loggedInOrFail, auth.can_READ];
174
+
175
+ // 404 for an organization that is not there. The role covers the whole organization, so the person
176
+ // must be able to read all of it.
177
+ const check = (req, res, next) => {
178
+ if (!orgs()[req.params.org]) return bad(res, 404, 'No such organization');
179
+ if (!has(req, 'READ')) return bad(res, 403, 'A login for OpenSearch Dashboards covers the whole organization, so it needs READ for the whole organization, not one project - use Grafana instead');
180
+ if (!USERNAME_OK.test(String(req.user.username))) return bad(res, 400, 'Your username has characters OpenSearch logins cannot (use letters, digits, . _ -)');
181
+ next();
182
+ };
183
+ const unavailable = (cfg, org) => {
184
+ if (!cfg || !cfg.node) return 'This server has no OpenSearch of its own set up (config.d/opensearch.yaml)';
185
+ if (!cfg.public) return 'This server has not said where OpenSearch can be reached from outside (config.d/opensearch.yaml, "public")';
186
+ if (!ORG_OK.test(org)) return 'OpenSearch logins need an organization id of lower-case letters and digits only';
187
+ return null;
188
+ };
189
+
190
+ router.get('/:org', ...guard, check, async (req, res, next) => {
191
+ try {
192
+ const cfg = localOpenSearch();
193
+ const why = unavailable(cfg, req.params.org);
194
+ if (why) return res.json({ available: false, reason: why });
195
+ const n = namesFor(req.params.org, req.user.username);
196
+ try {
197
+ const all = await listAccess(cfg, req.params.org);
198
+ res.json({ available: true, exists: all.includes(req.user.username), username: n.user, hosts: cfg.public, index: n.savedIndex,
199
+ // an organization administrator also sees whose there are, to remove one
200
+ all: has(req, 'ADMIN') ? all : undefined });
201
+ } catch (err) {
202
+ if (err instanceof AccessError) return res.json({ available: false, reason: err.message });
203
+ return res.json({ available: false, reason: 'OpenSearch did not answer' });
204
+ }
205
+ } catch (err) { next(err); }
206
+ });
207
+
208
+ router.post('/:org', ...guard, check, jsonOnly, async (req, res, next) => {
209
+ try {
210
+ const cfg = localOpenSearch();
211
+ const why = unavailable(cfg, req.params.org);
212
+ if (why) return bad(res, 400, why);
213
+ let made;
214
+ try { made = await createAccess(cfg, req.params.org, req.user.username); }
215
+ catch (err) {
216
+ if (err instanceof AccessError) return bad(res, 502, err.message);
217
+ throw err;
218
+ }
219
+ console.log('OpenSearch Dashboards login made for', JSON.stringify(req.user.username), '-', req.params.org);
220
+ res.set('Cache-Control', 'no-store'); // the password is in this reply and must not be kept anywhere
221
+ res.status(201).json({ ...made, hosts: cfg.public, config: dashboardsConfig(cfg, req.params.org, req.user.username, made.password) });
222
+ } catch (err) { next(err); }
223
+ });
224
+
225
+ router.post('/:org/revoke', ...guard, check, jsonOnly, express.json({ limit: '1kb' }), async (req, res, next) => {
226
+ try {
227
+ const cfg = localOpenSearch();
228
+ const why = unavailable(cfg, req.params.org);
229
+ if (why) return bad(res, 400, why);
230
+ const named = req.body && req.body.username !== undefined ? String(req.body.username) : req.user.username;
231
+ if (named !== req.user.username && !has(req, 'ADMIN')) return bad(res, 403, 'Only an administrator of the whole organization can remove someone else\'s login');
232
+ if (!USERNAME_OK.test(named)) return bad(res, 400, 'Not a username');
233
+ let removed;
234
+ try { removed = await revokeAccess(cfg, req.params.org, named); }
235
+ catch (err) {
236
+ if (err instanceof AccessError) return bad(res, 502, err.message);
237
+ throw err;
238
+ }
239
+ console.log('OpenSearch Dashboards login removed by', JSON.stringify(req.user.username), '-', req.params.org, JSON.stringify(named));
240
+ res.json({ revoked: removed });
241
+ } catch (err) { next(err); }
242
+ });
243
+
244
+ return router;
245
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  const ADDED = [
11
11
  { table: 'bridges', column: 'credential', type: 'TEXT' },
12
+ { table: 'grafana_tokens', column: 'user_id', type: 'INTEGER' },
12
13
  ];
13
14
 
14
15
  export function addMissingColumns(db, cb) {