shutro-erp 0.3.0 → 0.3.1
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/README.md +58 -58
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +31 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +31 -1
- package/dist/assistant/routes.js +1 -1
- package/dist/automation/engine.js +1 -1
- package/dist/automation/web/messages.js +2 -2
- package/dist/cli/apps.d.ts +1 -1
- package/dist/cli/apps.js +14 -14
- package/dist/cli/bin.js +3 -0
- package/dist/cli/index.js +11 -11
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +58 -36
- package/dist/cli/launch.js +2 -2
- package/dist/cli/project.d.ts +7 -7
- package/dist/cli/project.js +25 -25
- package/dist/cli/prompt.js +2 -2
- package/dist/cli/templates.d.ts +3 -3
- package/dist/cli/templates.js +29 -27
- package/dist/cli/ui.d.ts +1 -8
- package/dist/cli/ui.js +17 -11
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +31 -1
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +31 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +31 -1
- package/dist/l10n_in/einvoice.js +1 -1
- package/dist/l10n_in/web/messages.js +2 -2
- package/dist/mail/index.js +2 -2
- package/dist/modules-sdk/loader.js +2 -2
- package/dist/notify/messages.js +3 -3
- package/dist/notify/routes.js +1 -1
- package/dist/notify/web/messages.js +1 -1
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +31 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +31 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +31 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +31 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +31 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +31 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +31 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +31 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +31 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +31 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +31 -1
- package/dist/server/api/client.js +1 -1
- package/dist/server/api/openapi.js +17 -1
- package/dist/server/apps.js +5 -5
- package/dist/server/auth/api-keys.d.ts +13 -0
- package/dist/server/auth/api-keys.js +65 -0
- package/dist/server/auth/base.js +24 -0
- package/dist/server/auth/crypto.js +1 -1
- package/dist/server/auth/scopes.d.ts +12 -0
- package/dist/server/auth/scopes.js +51 -0
- package/dist/server/auth/service.d.ts +57 -1
- package/dist/server/auth/service.js +229 -5
- package/dist/server/demo.d.ts +1 -1
- package/dist/server/demo.js +1 -1
- package/dist/server/env-names.d.ts +2 -0
- package/dist/server/env-names.js +16 -0
- package/dist/server/host.js +5 -1
- package/dist/server/identity.d.ts +7 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/registry.js +1 -1
- package/dist/server/server.d.ts +5 -0
- package/dist/server/server.js +45 -5
- package/dist/settings/routes.js +1 -1
- package/dist/settings/web/ApiKeysPage.d.ts +8 -0
- package/dist/settings/web/ApiKeysPage.js +70 -0
- package/dist/settings/web/index.d.ts +1 -0
- package/dist/settings/web/index.js +4 -0
- package/dist/settings/web/messages.js +62 -0
- package/dist/storage/routes.js +1 -1
- package/dist/studio/build.js +1 -1
- package/dist/studio/generate.js +4 -4
- package/dist/studio/module.js +5 -5
- package/dist/studio/web/messages.js +2 -2
- package/dist/ui/app.js +26 -3
- package/dist/ui/i18n/messages.js +2 -2
- package/dist/ui/pwa/worker.js +2 -2
- package/dist/ui/registry.d.ts +15 -0
- package/dist/ui/registry.js +20 -0
- package/package.json +3 -3
|
@@ -282,7 +282,23 @@ export function openApiDocument(models, info) {
|
|
|
282
282
|
openapi: '3.1.0',
|
|
283
283
|
info,
|
|
284
284
|
paths: Object.assign({}, ...list.map(modelPaths)),
|
|
285
|
+
// A session (sign-in) or an API key (RFC 0017 §5.1); cookie sessions also send X-CSRF-Token on changes.
|
|
286
|
+
security: [{ session: [] }, { apiKey: [] }, { cookie: [] }],
|
|
285
287
|
components: {
|
|
288
|
+
securitySchemes: {
|
|
289
|
+
session: {
|
|
290
|
+
type: 'http',
|
|
291
|
+
scheme: 'bearer',
|
|
292
|
+
description: 'A session token from POST /api/v1/auth/login.',
|
|
293
|
+
},
|
|
294
|
+
apiKey: {
|
|
295
|
+
type: 'apiKey',
|
|
296
|
+
in: 'header',
|
|
297
|
+
name: 'X-Api-Key',
|
|
298
|
+
description: 'An API key (sk_…; also accepted as Authorization: Bearer). Its scopes — *, read:<app>, write:<app> — narrow the user’s rights.',
|
|
299
|
+
},
|
|
300
|
+
cookie: { type: 'apiKey', in: 'cookie', name: 'kosh_session' },
|
|
301
|
+
},
|
|
286
302
|
schemas: {
|
|
287
303
|
Problem: {
|
|
288
304
|
type: 'object',
|
|
@@ -344,5 +360,5 @@ export function openApiDocument(models, info) {
|
|
|
344
360
|
*/
|
|
345
361
|
export function openApiOf(modules) {
|
|
346
362
|
const models = publicApiModels(buildKoshRegistry(modules));
|
|
347
|
-
return openApiDocument(models.values(), { title: '
|
|
363
|
+
return openApiDocument(models.values(), { title: 'Shutro ERP API', version: KOSH_VERSION });
|
|
348
364
|
}
|
package/dist/server/apps.js
CHANGED
|
@@ -132,7 +132,7 @@ const digestOf = (rows) => createHash('sha256')
|
|
|
132
132
|
*/
|
|
133
133
|
export async function installedModules(source, catalogue, states = ['installed']) {
|
|
134
134
|
const driver = typeof source === 'string'
|
|
135
|
-
? pg.createDriver({ url: source, max: 1, applicationName: '
|
|
135
|
+
? pg.createDriver({ url: source, max: 1, applicationName: 'shutro-erp apps' })
|
|
136
136
|
: source;
|
|
137
137
|
try {
|
|
138
138
|
const rows = await readInstalledApps(driver);
|
|
@@ -188,7 +188,7 @@ export async function prepareApps(options) {
|
|
|
188
188
|
await restoreAppMigrations(driver, options.dir);
|
|
189
189
|
const pending = (await migrate.migrationStatus({ driver, dir: options.dir })).pending;
|
|
190
190
|
if (pending.length > 0)
|
|
191
|
-
throw new AppPlanError(`${pending.length} migration file(s) are not applied yet (${pending.map((f) => `${f.version}_${f.name}`).join(', ')}).`, 'Run
|
|
191
|
+
throw new AppPlanError(`${pending.length} migration file(s) are not applied yet (${pending.map((f) => `${f.version}_${f.name}`).join(', ')}).`, 'Run shutro-erp migrate first.');
|
|
192
192
|
const current = await installedApps(driver, catalogue);
|
|
193
193
|
const plan = planApps(catalogue, current, options.change, options.protect ?? ['base']);
|
|
194
194
|
const registryOf = (rows, states) => buildKoshRegistry(servedModules(catalogue, rows, states));
|
|
@@ -201,7 +201,7 @@ export async function prepareApps(options) {
|
|
|
201
201
|
});
|
|
202
202
|
// An upgrade is exactly that difference: the upgraded modules' code ahead of the database.
|
|
203
203
|
if (drift.statements.length > 0 && plan.upgrade.length === 0)
|
|
204
|
-
throw new AppPlanError(`The database does not match the installed modules (${drift.statements.length} change(s) not migrated).`, 'Run
|
|
204
|
+
throw new AppPlanError(`The database does not match the installed modules (${drift.statements.length} change(s) not migrated).`, 'Run shutro-erp migrate first.');
|
|
205
205
|
const runtime = registryOf(plan.after, ['installed']);
|
|
206
206
|
const schema = registryOf(plan.after, ['installed', 'disabled']);
|
|
207
207
|
const migration = await migrate.planMigration({
|
|
@@ -212,8 +212,8 @@ export async function prepareApps(options) {
|
|
|
212
212
|
const removeData = options.removeData === true && plan.upgrade.length > 0;
|
|
213
213
|
if (migration.destructive.length > 0 && !removeData)
|
|
214
214
|
throw new AppPlanError(`The change would drop or alter data: ${migration.destructive.map((op) => migrate.renderOperation(op)).join('; ')}.`, plan.upgrade.length > 0
|
|
215
|
-
? 'Upgrade with "remove data" (
|
|
216
|
-
: 'App changes never destroy data; review the modules, or use
|
|
215
|
+
? 'Upgrade with "remove data" (shutro-erp apps upgrade --remove-data) to drop them, or keep the fields in the code.'
|
|
216
|
+
: 'App changes never destroy data; review the modules, or use shutro-erp migrate --plan.');
|
|
217
217
|
// A disabled module's columns stay in other modules' tables: a NOT NULL one without a database default (a function
|
|
218
218
|
// default or a compute lives in the ORM) would make every insert there fail.
|
|
219
219
|
const orphaned = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API keys and service accounts over HTTP (RFC 0017 §5.1). With a session only (a key cannot make keys):
|
|
3
|
+
*
|
|
4
|
+
* - `GET /api/v1/auth/api-keys` your keys (and every service account's, for administrators)
|
|
5
|
+
* - `POST /api/v1/auth/api-keys` `{ name, scopes, expiresInDays?, user? }` → the key and its secret, once
|
|
6
|
+
* - `POST /api/v1/auth/api-keys/:id/revoke` at once
|
|
7
|
+
* - `POST /api/v1/auth/service-accounts` `{ login, name, groups }` (administrators)
|
|
8
|
+
* - `GET /api/v1/auth/scopes` the scopes a key may have: `*` and the installed apps'
|
|
9
|
+
*/
|
|
10
|
+
import type { FastifyInstance } from 'fastify';
|
|
11
|
+
import type { KoshRegistry } from '../registry.js';
|
|
12
|
+
import type { AuthService } from './service.js';
|
|
13
|
+
export declare function registerApiKeyRoutes(app: FastifyInstance, auth: AuthService, registry: KoshRegistry): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HttpProblem } from '../errors.js';
|
|
2
|
+
import { parseScopes } from './scopes.js';
|
|
3
|
+
const body = (request) => typeof request.body === 'object' && request.body !== null
|
|
4
|
+
? request.body
|
|
5
|
+
: {};
|
|
6
|
+
export function registerApiKeyRoutes(app, auth, registry) {
|
|
7
|
+
const apps = () => new Set(registry.loaded.modules.map((m) => m.name));
|
|
8
|
+
const config = { config: { auth: false } };
|
|
9
|
+
app.get('/api/v1/auth/scopes', config, async (request) => {
|
|
10
|
+
await auth.requireSession(request);
|
|
11
|
+
return {
|
|
12
|
+
scopes: [
|
|
13
|
+
{ scope: '*', label: 'Everything you may do' },
|
|
14
|
+
...registry.loaded.modules.flatMap((m) => [
|
|
15
|
+
{ scope: `read:${m.name}`, label: `Read ${m.name}` },
|
|
16
|
+
{ scope: `write:${m.name}`, label: `Read and change ${m.name}` },
|
|
17
|
+
]),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
app.get('/api/v1/auth/api-keys', config, async (request) => {
|
|
22
|
+
const session = await auth.requireSession(request);
|
|
23
|
+
return { keys: await auth.listApiKeys(session) };
|
|
24
|
+
});
|
|
25
|
+
app.post('/api/v1/auth/api-keys', config, async (request, reply) => {
|
|
26
|
+
const session = await auth.requireSession(request);
|
|
27
|
+
const input = body(request);
|
|
28
|
+
const scopes = parseScopes(Array.isArray(input['scopes'])
|
|
29
|
+
? input['scopes'].map(String)
|
|
30
|
+
: String(input['scopes'] ?? ''), apps());
|
|
31
|
+
const user = input['user'] === undefined ? undefined : Number(input['user']);
|
|
32
|
+
if (user !== undefined && !(Number.isInteger(user) && user > 0))
|
|
33
|
+
throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'user is a user id.');
|
|
34
|
+
const days = input['expiresInDays'] === undefined ? undefined : Number(input['expiresInDays']);
|
|
35
|
+
const created = await auth.createApiKey(session, {
|
|
36
|
+
name: String(input['name'] ?? ''),
|
|
37
|
+
scopes,
|
|
38
|
+
...(user === undefined ? {} : { user }),
|
|
39
|
+
...(days === undefined ? {} : { expiresInDays: days }),
|
|
40
|
+
}, request.ip);
|
|
41
|
+
return reply.status(201).send(created);
|
|
42
|
+
});
|
|
43
|
+
app.post('/api/v1/auth/api-keys/:id/revoke', config, async (request, reply) => {
|
|
44
|
+
const session = await auth.requireSession(request);
|
|
45
|
+
const id = Number(request.params.id);
|
|
46
|
+
if (!(Number.isInteger(id) && id > 0))
|
|
47
|
+
throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The key id is a positive integer.');
|
|
48
|
+
await auth.revokeApiKey(session, id, request.ip);
|
|
49
|
+
return reply.status(204).send();
|
|
50
|
+
});
|
|
51
|
+
app.post('/api/v1/auth/service-accounts', config, async (request, reply) => {
|
|
52
|
+
const session = await auth.requireSession(request);
|
|
53
|
+
const input = body(request);
|
|
54
|
+
const login = String(input['login'] ?? '').trim();
|
|
55
|
+
const name = String(input['name'] ?? '').trim();
|
|
56
|
+
if (!/^[A-Za-z0-9._@-]{3,64}$/.test(login) || name === '')
|
|
57
|
+
throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'login (3–64 letters, digits, . _ @ -) and name are required.');
|
|
58
|
+
const groups = Array.isArray(input['groups']) ? input['groups'].map(String) : [];
|
|
59
|
+
const unknown = groups.filter((g) => !registry.registry.security.groups.has(g));
|
|
60
|
+
if (unknown.length > 0)
|
|
61
|
+
throw new HttpProblem(422, 'invalid-groups', 'Invalid roles', `Unknown roles: ${unknown.join(', ')}.`);
|
|
62
|
+
const created = await auth.createServiceAccount(session, { login, name, groups }, request.ip);
|
|
63
|
+
return reply.status(201).send(created);
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/server/auth/base.js
CHANGED
|
@@ -53,6 +53,8 @@ const ResUsers = defineModel('res.users', {
|
|
|
53
53
|
companies: f.many2many('res.company'),
|
|
54
54
|
lang: f.char({ default: 'en_US' }),
|
|
55
55
|
tz: f.char({ default: 'UTC' }),
|
|
56
|
+
/** A service account (RFC 0017 §5.1): no password, no sign-in; acts only through API keys, with its roles. */
|
|
57
|
+
isService: f.boolean({ default: false, label: 'Service account' }),
|
|
56
58
|
},
|
|
57
59
|
});
|
|
58
60
|
/** Password hash and TOTP state of a user. Private: never served by the generated API. */
|
|
@@ -81,6 +83,26 @@ const AuthSession = defineModel('auth.session', {
|
|
|
81
83
|
userAgent: f.char(),
|
|
82
84
|
},
|
|
83
85
|
});
|
|
86
|
+
/**
|
|
87
|
+
* An API key (RFC 0017 §5.1): acts as its user (a person or a service account) within its scopes, until it expires
|
|
88
|
+
* or is revoked. The secret is shown once and stored only as a SHA-256 digest; the prefix identifies it. Private.
|
|
89
|
+
*/
|
|
90
|
+
const AuthApiKey = defineModel('auth.api_key', {
|
|
91
|
+
fields: {
|
|
92
|
+
name: f.char({ required: true }),
|
|
93
|
+
user: f.many2one('res.users', { required: true, ondelete: 'cascade' }),
|
|
94
|
+
tokenHash: f.char({ required: true, index: 'unique' }),
|
|
95
|
+
/** The secret's start (`sk_ab12cd34`), to tell keys apart. */
|
|
96
|
+
prefix: f.char({ required: true }),
|
|
97
|
+
/** Space-separated: `*`, `read:<app>`, `write:<app>`. */
|
|
98
|
+
scopes: f.text({ required: true }),
|
|
99
|
+
expiresAt: f.datetime({ required: true }),
|
|
100
|
+
lastUsedAt: f.datetime(),
|
|
101
|
+
lastUsedIp: f.char(),
|
|
102
|
+
revoked: f.boolean({ default: false }),
|
|
103
|
+
issuedBy: f.many2one('res.users', { ondelete: 'set null' }),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
84
106
|
/**
|
|
85
107
|
* A group or role a user holds in one company (or every company when `company` is empty). Private. Users holding
|
|
86
108
|
* any cannot be deleted (`restrict`): they are archived, which the "keep one administrator" guard checks.
|
|
@@ -171,6 +193,7 @@ export const baseShutro = defineModule({
|
|
|
171
193
|
ResUsers,
|
|
172
194
|
AuthCredential,
|
|
173
195
|
AuthSession,
|
|
196
|
+
AuthApiKey,
|
|
174
197
|
AuthUserGroup,
|
|
175
198
|
AuthUserGrant,
|
|
176
199
|
AuditEvent,
|
|
@@ -188,6 +211,7 @@ export const base = defineKoshModule({
|
|
|
188
211
|
privateModels: [
|
|
189
212
|
'auth.credential',
|
|
190
213
|
'auth.session',
|
|
214
|
+
'auth.api_key',
|
|
191
215
|
'auth.user_group',
|
|
192
216
|
'auth.user_grant',
|
|
193
217
|
'audit.event',
|
|
@@ -94,6 +94,6 @@ export function verifyTotp(secret, code, time = Date.now()) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
/** The `otpauth://` URI authenticator apps scan. */
|
|
97
|
-
export function otpauthUri(secret, account, issuer = '
|
|
97
|
+
export function otpauthUri(secret, account, issuer = 'Shutro ERP') {
|
|
98
98
|
return `otpauth://totp/${encodeURIComponent(`${issuer}:${account}`)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
|
99
99
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Access = 'read' | 'write';
|
|
2
|
+
/** The scopes in `text` (space or comma separated), checked against the installed apps. */
|
|
3
|
+
export declare function parseScopes(input: string | readonly string[], apps: ReadonlySet<string>): string[];
|
|
4
|
+
/** Whether `scopes` allow `access` to `app` (`null`: a route of no app, which needs `*`). */
|
|
5
|
+
export declare function scopesAllow(scopes: readonly string[], app: string | null, access: Access): boolean;
|
|
6
|
+
/** The access a request method needs. */
|
|
7
|
+
export declare const accessOf: (method: string) => Access;
|
|
8
|
+
/**
|
|
9
|
+
* Refuses a request an API key's scopes do not cover. `route` is the route's pattern (`/api/v1/models/:model`),
|
|
10
|
+
* `app` the app it belongs to, or null.
|
|
11
|
+
*/
|
|
12
|
+
export declare function enforceScopes(scopes: readonly string[], method: string, route: string, app: string | null): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key scopes (RFC 0017 §5.1): they narrow what a key's user may do, never widen it. `*` is everything the user
|
|
3
|
+
* may do; `read:<app>` reads an app's models and routes; `write:<app>` also changes them (it implies read). A route
|
|
4
|
+
* belongs to an app when the app's module registered it, or when it serves one of the app's models; a route of no
|
|
5
|
+
* app needs `*`. Keys cannot manage credentials (sign-in, 2FA, passwords, keys): those need a session.
|
|
6
|
+
*/
|
|
7
|
+
import { HttpProblem } from '../errors.js';
|
|
8
|
+
const SCOPE = /^(\*|(read|write):[a-z][a-z0-9_]*)$/;
|
|
9
|
+
/** The scopes in `text` (space or comma separated), checked against the installed apps. */
|
|
10
|
+
export function parseScopes(input, apps) {
|
|
11
|
+
const list = (typeof input === 'string' ? input.split(/[\s,]+/) : [...input])
|
|
12
|
+
.map((s) => s.trim())
|
|
13
|
+
.filter((s) => s !== '');
|
|
14
|
+
if (list.length === 0)
|
|
15
|
+
throw new HttpProblem(400, 'invalid-scopes', 'Invalid scopes', 'Give at least one scope: *, read:<app> or write:<app>.');
|
|
16
|
+
for (const scope of list) {
|
|
17
|
+
if (!SCOPE.test(scope))
|
|
18
|
+
throw new HttpProblem(400, 'invalid-scopes', 'Invalid scopes', `"${scope}" is not a scope: *, read:<app> or write:<app>.`);
|
|
19
|
+
const app = scope.split(':')[1];
|
|
20
|
+
if (app !== undefined && !apps.has(app))
|
|
21
|
+
throw new HttpProblem(400, 'invalid-scopes', 'Invalid scopes', `"${app}" is not an installed app.`);
|
|
22
|
+
}
|
|
23
|
+
return [...new Set(list)].sort();
|
|
24
|
+
}
|
|
25
|
+
/** Whether `scopes` allow `access` to `app` (`null`: a route of no app, which needs `*`). */
|
|
26
|
+
export function scopesAllow(scopes, app, access) {
|
|
27
|
+
if (scopes.includes('*'))
|
|
28
|
+
return true;
|
|
29
|
+
if (app === null)
|
|
30
|
+
return false;
|
|
31
|
+
return scopes.includes(`write:${app}`) || (access === 'read' && scopes.includes(`read:${app}`));
|
|
32
|
+
}
|
|
33
|
+
/** The access a request method needs. */
|
|
34
|
+
export const accessOf = (method) => method === 'GET' || method === 'HEAD' || method === 'OPTIONS' ? 'read' : 'write';
|
|
35
|
+
/** Credential routes an API key may still use (who am I). */
|
|
36
|
+
const KEY_AUTH_ROUTES = new Set(['GET /api/v1/auth/me']);
|
|
37
|
+
/**
|
|
38
|
+
* Refuses a request an API key's scopes do not cover. `route` is the route's pattern (`/api/v1/models/:model`),
|
|
39
|
+
* `app` the app it belongs to, or null.
|
|
40
|
+
*/
|
|
41
|
+
export function enforceScopes(scopes, method, route, app) {
|
|
42
|
+
if (route.startsWith('/api/v1/auth/') && !KEY_AUTH_ROUTES.has(`${method} ${route}`))
|
|
43
|
+
throw new HttpProblem(403, 'api-key-not-allowed', 'Not with an API key', 'Signing in, two-factor, passwords and API keys are managed with a session, not an API key.');
|
|
44
|
+
if (KEY_AUTH_ROUTES.has(`${method} ${route}`))
|
|
45
|
+
return;
|
|
46
|
+
const access = accessOf(method);
|
|
47
|
+
if (!scopesAllow(scopes, app, access))
|
|
48
|
+
throw new HttpProblem(403, 'scope', 'Outside the API key’s scopes', app === null
|
|
49
|
+
? 'This route belongs to no app: it needs a key with the * scope.'
|
|
50
|
+
: `This needs the ${access === 'read' ? `read:${app} or write:${app}` : `write:${app}`} scope.`, 'Create a key with the scope it needs.');
|
|
51
|
+
}
|
|
@@ -45,6 +45,20 @@ export interface UserInfo {
|
|
|
45
45
|
/** Administrator rights are withheld until this session has a second factor (enable or verify 2FA). */
|
|
46
46
|
readonly administratorNeedsSecondFactor: boolean;
|
|
47
47
|
}
|
|
48
|
+
/** An API key as listed (never its secret). */
|
|
49
|
+
export interface ApiKeyInfo {
|
|
50
|
+
readonly id: number;
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly user: number;
|
|
53
|
+
/** The user's name (yours, or a service account's). */
|
|
54
|
+
readonly userName: string;
|
|
55
|
+
readonly prefix: string;
|
|
56
|
+
readonly scopes: readonly string[];
|
|
57
|
+
readonly expiresAt: string;
|
|
58
|
+
readonly lastUsedAt: string | null;
|
|
59
|
+
readonly lastUsedIp: string | null;
|
|
60
|
+
readonly revoked: boolean;
|
|
61
|
+
}
|
|
48
62
|
export declare const SESSION_COOKIE = "kosh_session";
|
|
49
63
|
/** Milliseconds of a datetime value as Shutro returns it (`Date`, or an ISO text), or `null`. */
|
|
50
64
|
export declare function timeOf(value: unknown): number | null;
|
|
@@ -83,8 +97,50 @@ export declare class AuthService {
|
|
|
83
97
|
requireSession(request: FastifyRequest, options?: {
|
|
84
98
|
readonly allowPending?: boolean;
|
|
85
99
|
}): Promise<SessionInfo>;
|
|
86
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Authenticator for the server (RFC 0002 §6.2): the session's user — or the API key's (RFC 0017 §5.1), with its
|
|
102
|
+
* scopes —, company from `X-Company`, groups for it.
|
|
103
|
+
*/
|
|
87
104
|
authenticate(request: FastifyRequest): Promise<RequestIdentity | null>;
|
|
105
|
+
/**
|
|
106
|
+
* The request's API key (RFC 0017 §5.1): `X-Api-Key: sk_…` or `Authorization: Bearer sk_…`; null without one.
|
|
107
|
+
* @throws {HttpProblem} 401 for an `X-Api-Key` that is unknown, revoked or expired
|
|
108
|
+
*/
|
|
109
|
+
apiKeyOf(request: FastifyRequest): Promise<{
|
|
110
|
+
id: number;
|
|
111
|
+
user: number;
|
|
112
|
+
scopes: string[];
|
|
113
|
+
} | null>;
|
|
114
|
+
/**
|
|
115
|
+
* A new API key for `user` (the session's own, or a service account's for administrators), with `scopes` already
|
|
116
|
+
* checked against the installed apps. The secret is returned once.
|
|
117
|
+
*/
|
|
118
|
+
createApiKey(session: SessionInfo, input: {
|
|
119
|
+
readonly user?: number;
|
|
120
|
+
readonly name: string;
|
|
121
|
+
readonly scopes: readonly string[];
|
|
122
|
+
readonly expiresInDays?: number;
|
|
123
|
+
}, ip?: string): Promise<ApiKeyInfo & {
|
|
124
|
+
token: string;
|
|
125
|
+
}>;
|
|
126
|
+
/** The session user's keys, and every service account's for administrators. */
|
|
127
|
+
listApiKeys(session: SessionInfo): Promise<ApiKeyInfo[]>;
|
|
128
|
+
/** Revokes a key at once (the session's own, or a service account's for administrators). */
|
|
129
|
+
revokeApiKey(session: SessionInfo, id: number, ip?: string): Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* A service account (RFC 0017 §5.1): a user without a password who acts only through API keys, with `groups`
|
|
132
|
+
* (checked by the caller) in the administrator's companies. Administrators only.
|
|
133
|
+
*/
|
|
134
|
+
createServiceAccount(session: SessionInfo, input: {
|
|
135
|
+
readonly login: string;
|
|
136
|
+
readonly name: string;
|
|
137
|
+
readonly groups: readonly string[];
|
|
138
|
+
}, ip?: string): Promise<{
|
|
139
|
+
id: number;
|
|
140
|
+
login: string;
|
|
141
|
+
name: string;
|
|
142
|
+
groups: string[];
|
|
143
|
+
}>;
|
|
88
144
|
/** Identity loader for Shutro (`createShutro({ identity })`): the user is active; companies and groups from the database. */
|
|
89
145
|
verify(uid: number): Promise<VerifiedIdentity | null>;
|
|
90
146
|
/** The signed-in user's state. */
|
|
@@ -36,6 +36,7 @@ export class AuthService {
|
|
|
36
36
|
#shutro;
|
|
37
37
|
#options;
|
|
38
38
|
#cache = new Map();
|
|
39
|
+
#keyCache = new Map();
|
|
39
40
|
#dummy = hashPassword(randomToken());
|
|
40
41
|
constructor(shutro, options = {}) {
|
|
41
42
|
this.#shutro = shutro;
|
|
@@ -212,14 +213,27 @@ export class AuthService {
|
|
|
212
213
|
throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
|
|
213
214
|
return session;
|
|
214
215
|
}
|
|
215
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* Authenticator for the server (RFC 0002 §6.2): the session's user — or the API key's (RFC 0017 §5.1), with its
|
|
218
|
+
* scopes —, company from `X-Company`, groups for it.
|
|
219
|
+
*/
|
|
216
220
|
async authenticate(request) {
|
|
221
|
+
const key = await this.apiKeyOf(request);
|
|
222
|
+
if (key !== null)
|
|
223
|
+
return this.#identity(request, key.user, {
|
|
224
|
+
elevated: false,
|
|
225
|
+
scopes: key.scopes,
|
|
226
|
+
apiKey: key.id,
|
|
227
|
+
});
|
|
217
228
|
const session = await this.session(request);
|
|
218
229
|
if (session === null)
|
|
219
230
|
return null;
|
|
220
231
|
if (session.twoFactorRequired)
|
|
221
232
|
throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
|
|
222
|
-
|
|
233
|
+
return this.#identity(request, session.user, { elevated: session.secondFactor });
|
|
234
|
+
}
|
|
235
|
+
async #identity(request, user, options) {
|
|
236
|
+
const info = await this.#identityOf(user);
|
|
223
237
|
if (info === null)
|
|
224
238
|
return null;
|
|
225
239
|
const asked = Number(request.headers['x-company']);
|
|
@@ -229,8 +243,8 @@ export class AuthService {
|
|
|
229
243
|
if (company === undefined)
|
|
230
244
|
throw new HttpProblem(403, 'no-company', 'No company', 'This user is not allowed in any company.', 'An administrator assigns the companies of the user.');
|
|
231
245
|
// Mandatory 2FA for administrators (RFC 0003 §5.2): their administrator groups count only in sessions that
|
|
232
|
-
// entered a second factor, while 2FA is on. The user keeps every other group.
|
|
233
|
-
const elevated =
|
|
246
|
+
// entered a second factor, while 2FA is on — never through an API key. The user keeps every other group.
|
|
247
|
+
const elevated = options.elevated && info.twoFactorEnabled;
|
|
234
248
|
const groups = info.assignments
|
|
235
249
|
.filter((a) => a.company === null || a.company === company)
|
|
236
250
|
.map((a) => a.group)
|
|
@@ -241,15 +255,225 @@ export class AuthService {
|
|
|
241
255
|
(g.expiresAt === null || g.expiresAt > now))
|
|
242
256
|
.map((g) => g.grant);
|
|
243
257
|
return {
|
|
244
|
-
user
|
|
258
|
+
user,
|
|
245
259
|
company,
|
|
246
260
|
companies: info.companies,
|
|
247
261
|
groups: [...new Set(groups)],
|
|
248
262
|
grants: [...new Set(grants)],
|
|
249
263
|
lang: info.lang,
|
|
250
264
|
tz: info.tz,
|
|
265
|
+
...(options.scopes === undefined ? {} : { scopes: options.scopes }),
|
|
266
|
+
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
|
251
267
|
};
|
|
252
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The request's API key (RFC 0017 §5.1): `X-Api-Key: sk_…` or `Authorization: Bearer sk_…`; null without one.
|
|
271
|
+
* @throws {HttpProblem} 401 for an `X-Api-Key` that is unknown, revoked or expired
|
|
272
|
+
*/
|
|
273
|
+
async apiKeyOf(request) {
|
|
274
|
+
const header = request.headers['x-api-key'];
|
|
275
|
+
const explicit = typeof header === 'string' && header !== '' ? header : undefined;
|
|
276
|
+
const bearer = /^Bearer\s+(sk_\S+)$/i.exec(String(request.headers.authorization ?? ''))?.[1];
|
|
277
|
+
const token = explicit ?? bearer;
|
|
278
|
+
if (token === undefined)
|
|
279
|
+
return null;
|
|
280
|
+
const tokenHash = digest(token);
|
|
281
|
+
const cached = this.#keyCache.get(tokenHash);
|
|
282
|
+
if (cached !== undefined && Date.now() - cached.at < this.#options.cacheMs)
|
|
283
|
+
return cached.key;
|
|
284
|
+
const key = await this.#system('api key', async (env) => {
|
|
285
|
+
const row = one(await fetch(model(env, 'auth.api_key').search({ where: { tokenHash, revoked: false } }), [
|
|
286
|
+
'user',
|
|
287
|
+
'scopes',
|
|
288
|
+
'expiresAt',
|
|
289
|
+
'lastUsedAt',
|
|
290
|
+
]));
|
|
291
|
+
if (row === undefined)
|
|
292
|
+
return null;
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
if ((timeOf(row['expiresAt']) ?? 0) <= now)
|
|
295
|
+
return null;
|
|
296
|
+
// Archived users' keys stop working with them.
|
|
297
|
+
if ((await model(env, 'res.users').search({ where: { id: row['user'] } })).length === 0)
|
|
298
|
+
return null;
|
|
299
|
+
if ((timeOf(row['lastUsedAt']) ?? 0) + 5 * 60_000 < now)
|
|
300
|
+
await model(env, 'auth.api_key')
|
|
301
|
+
.browse([row['id']])
|
|
302
|
+
.write({ lastUsedAt: new Date(now), lastUsedIp: request.ip });
|
|
303
|
+
return {
|
|
304
|
+
id: row['id'],
|
|
305
|
+
user: row['user'],
|
|
306
|
+
scopes: String(row['scopes'])
|
|
307
|
+
.split(' ')
|
|
308
|
+
.filter((x) => x !== ''),
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
if (key === null) {
|
|
312
|
+
this.#keyCache.delete(tokenHash);
|
|
313
|
+
// A bearer token that is no key may still be a session's.
|
|
314
|
+
if (explicit === undefined)
|
|
315
|
+
return null;
|
|
316
|
+
throw unauthenticated('invalid-api-key', 'The API key is unknown, revoked or expired.', 'Create a new key (Settings › API keys).');
|
|
317
|
+
}
|
|
318
|
+
this.#keyCache.set(tokenHash, { at: Date.now(), key });
|
|
319
|
+
return key;
|
|
320
|
+
}
|
|
321
|
+
/** May `actor` manage `user`'s keys: their own, or a service account's when the actor is an administrator. */
|
|
322
|
+
async #mayManageKeysOf(env, session, user) {
|
|
323
|
+
if (user === session.user)
|
|
324
|
+
return true;
|
|
325
|
+
const [target] = await fetch(model(env, 'res.users').search({ where: { id: user } }), [
|
|
326
|
+
'isService',
|
|
327
|
+
]);
|
|
328
|
+
return (target?.['isService'] === true &&
|
|
329
|
+
session.secondFactor &&
|
|
330
|
+
(await this.#holdsAdministrator(env, session.user)));
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* A new API key for `user` (the session's own, or a service account's for administrators), with `scopes` already
|
|
334
|
+
* checked against the installed apps. The secret is returned once.
|
|
335
|
+
*/
|
|
336
|
+
async createApiKey(session, input, ip) {
|
|
337
|
+
const days = input.expiresInDays ?? 365;
|
|
338
|
+
if (!Number.isInteger(days) || days < 1 || days > 730)
|
|
339
|
+
throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'expiresInDays is 1 to 730.');
|
|
340
|
+
const name = input.name.trim();
|
|
341
|
+
if (name === '' || name.length > 100)
|
|
342
|
+
throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'A key has a name of up to 100 characters.');
|
|
343
|
+
const user = input.user ?? session.user;
|
|
344
|
+
return this.#system('create api key', async (env) => {
|
|
345
|
+
if (!(await this.#mayManageKeysOf(env, session, user)))
|
|
346
|
+
throw new HttpProblem(403, 'access', 'Forbidden', 'Keys are made for yourself, or by an administrator for a service account.');
|
|
347
|
+
const token = `sk_${randomToken(30)
|
|
348
|
+
.replace(/[^A-Za-z0-9]/g, '')
|
|
349
|
+
.slice(0, 40)}`;
|
|
350
|
+
const prefix = token.slice(0, 11);
|
|
351
|
+
const created = await model(env, 'auth.api_key').create([
|
|
352
|
+
{
|
|
353
|
+
name,
|
|
354
|
+
user,
|
|
355
|
+
tokenHash: digest(token),
|
|
356
|
+
prefix,
|
|
357
|
+
scopes: input.scopes.join(' '),
|
|
358
|
+
expiresAt: new Date(Date.now() + days * 86_400_000),
|
|
359
|
+
issuedBy: session.user,
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
const id = created.ids[0];
|
|
363
|
+
await audit(env, {
|
|
364
|
+
kind: 'auth.api_key_created',
|
|
365
|
+
summary: `API key "${name}" (${prefix}…) created for user ${user}: ${input.scopes.join(' ')}.`,
|
|
366
|
+
actor: session.user,
|
|
367
|
+
company: null,
|
|
368
|
+
details: { key: id, user, scopes: [...input.scopes], expiresInDays: days },
|
|
369
|
+
...(ip === undefined ? {} : { ip }),
|
|
370
|
+
});
|
|
371
|
+
const [info] = await this.#keys(env, [id]);
|
|
372
|
+
return { ...info, token };
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async #keys(env, ids, users) {
|
|
376
|
+
const where = ids !== undefined ? { id: { in: [...ids] } } : { user: { in: [...(users ?? [])] } };
|
|
377
|
+
const rows = await fetch(model(env, 'auth.api_key').search({ where, order: ['id desc'] }), [
|
|
378
|
+
'name',
|
|
379
|
+
'user',
|
|
380
|
+
'prefix',
|
|
381
|
+
'scopes',
|
|
382
|
+
'expiresAt',
|
|
383
|
+
'lastUsedAt',
|
|
384
|
+
'lastUsedIp',
|
|
385
|
+
'revoked',
|
|
386
|
+
'createdAt',
|
|
387
|
+
]);
|
|
388
|
+
const userIds = [...new Set(rows.map((r) => r['user']))];
|
|
389
|
+
const names = new Map((userIds.length === 0
|
|
390
|
+
? []
|
|
391
|
+
: await fetch(model(env, 'res.users').search({ where: { id: { in: userIds } } }), ['name'])).map((u) => [u['id'], String(u['name'])]));
|
|
392
|
+
return rows.map((r) => ({
|
|
393
|
+
id: r['id'],
|
|
394
|
+
name: String(r['name']),
|
|
395
|
+
user: r['user'],
|
|
396
|
+
userName: names.get(r['user']) ?? '',
|
|
397
|
+
prefix: String(r['prefix']),
|
|
398
|
+
scopes: String(r['scopes'])
|
|
399
|
+
.split(' ')
|
|
400
|
+
.filter((x) => x !== ''),
|
|
401
|
+
expiresAt: new Date(timeOf(r['expiresAt']) ?? 0).toISOString(),
|
|
402
|
+
lastUsedAt: timeOf(r['lastUsedAt']) === null
|
|
403
|
+
? null
|
|
404
|
+
: new Date(timeOf(r['lastUsedAt']) ?? 0).toISOString(),
|
|
405
|
+
lastUsedIp: typeof r['lastUsedIp'] === 'string' ? r['lastUsedIp'] : null,
|
|
406
|
+
revoked: r['revoked'] === true,
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
/** The session user's keys, and every service account's for administrators. */
|
|
410
|
+
async listApiKeys(session) {
|
|
411
|
+
return this.#system('list api keys', async (env) => {
|
|
412
|
+
const users = [session.user];
|
|
413
|
+
if (session.secondFactor && (await this.#holdsAdministrator(env, session.user)))
|
|
414
|
+
users.push(...(await model(env, 'res.users').search({ where: { isService: true } })).ids);
|
|
415
|
+
return this.#keys(env, undefined, users);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/** Revokes a key at once (the session's own, or a service account's for administrators). */
|
|
419
|
+
async revokeApiKey(session, id, ip) {
|
|
420
|
+
await this.#system('revoke api key', async (env) => {
|
|
421
|
+
const [key] = await fetch(model(env, 'auth.api_key').search({ where: { id } }), [
|
|
422
|
+
'user',
|
|
423
|
+
'prefix',
|
|
424
|
+
'tokenHash',
|
|
425
|
+
]);
|
|
426
|
+
if (key === undefined || !(await this.#mayManageKeysOf(env, session, key['user'])))
|
|
427
|
+
throw new HttpProblem(404, 'missing-record', 'Not found', `There is no API key ${id} of yours.`);
|
|
428
|
+
await model(env, 'auth.api_key').browse([id]).write({ revoked: true });
|
|
429
|
+
this.#keyCache.delete(String(key['tokenHash']));
|
|
430
|
+
await audit(env, {
|
|
431
|
+
kind: 'auth.api_key_revoked',
|
|
432
|
+
summary: `API key ${String(key['prefix'])}… revoked.`,
|
|
433
|
+
actor: session.user,
|
|
434
|
+
company: null,
|
|
435
|
+
details: { key: id },
|
|
436
|
+
...(ip === undefined ? {} : { ip }),
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* A service account (RFC 0017 §5.1): a user without a password who acts only through API keys, with `groups`
|
|
442
|
+
* (checked by the caller) in the administrator's companies. Administrators only.
|
|
443
|
+
*/
|
|
444
|
+
async createServiceAccount(session, input, ip) {
|
|
445
|
+
return this.#system(`create service account ${input.login}`, async (env) => {
|
|
446
|
+
if (!(session.secondFactor && (await this.#holdsAdministrator(env, session.user))))
|
|
447
|
+
throw new HttpProblem(403, 'access', 'Forbidden', 'Service accounts are created by administrators.');
|
|
448
|
+
const administrators = new Set(env.access.administratorGroups());
|
|
449
|
+
if (input.groups.some((g) => administrators.has(g)))
|
|
450
|
+
throw new HttpProblem(422, 'invalid-groups', 'Invalid roles', 'A service account cannot be an administrator.');
|
|
451
|
+
const [me] = await fetch(model(env, 'res.users').search({ where: { id: session.user } }), [
|
|
452
|
+
'company',
|
|
453
|
+
]);
|
|
454
|
+
const company = typeof me?.['company'] === 'number' ? me['company'] : undefined;
|
|
455
|
+
const created = await model(env, 'res.users').create([
|
|
456
|
+
{
|
|
457
|
+
login: input.login,
|
|
458
|
+
name: input.name,
|
|
459
|
+
isService: true,
|
|
460
|
+
...(company === undefined ? {} : { company, companies: { set: [company] } }),
|
|
461
|
+
},
|
|
462
|
+
]);
|
|
463
|
+
const id = created.ids[0];
|
|
464
|
+
if (input.groups.length > 0)
|
|
465
|
+
await model(env, 'auth.user_group').create(input.groups.map((group) => ({ user: id, group })));
|
|
466
|
+
await audit(env, {
|
|
467
|
+
kind: 'auth.service_account_created',
|
|
468
|
+
summary: `Service account ${input.login} created with ${input.groups.join(', ') || 'no roles'}.`,
|
|
469
|
+
actor: session.user,
|
|
470
|
+
company: null,
|
|
471
|
+
details: { user: id, groups: [...input.groups] },
|
|
472
|
+
...(ip === undefined ? {} : { ip }),
|
|
473
|
+
});
|
|
474
|
+
return { id, login: input.login, name: input.name, groups: [...input.groups] };
|
|
475
|
+
});
|
|
476
|
+
}
|
|
253
477
|
/** Identity loader for Shutro (`createShutro({ identity })`): the user is active; companies and groups from the database. */
|
|
254
478
|
async verify(uid) {
|
|
255
479
|
const info = await this.#identityOf(uid);
|
package/dist/server/demo.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { KoshModule } from '../modules-sdk/index.js';
|
|
|
8
8
|
import type { AuthService } from './auth/service.js';
|
|
9
9
|
import type { Driver, Shutro } from './orm.js';
|
|
10
10
|
/** The demo users' password (every demo user; printed by `kosh init --demo`). */
|
|
11
|
-
export declare const DEMO_PASSWORD = "
|
|
11
|
+
export declare const DEMO_PASSWORD = "shutro demo 2026";
|
|
12
12
|
/** What one module's seeder did. */
|
|
13
13
|
export interface DemoResult {
|
|
14
14
|
readonly module: string;
|
package/dist/server/demo.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { APPS_TABLE, appTableStatements } from './apps.js';
|
|
2
2
|
/** The demo users' password (every demo user; printed by `kosh init --demo`). */
|
|
3
|
-
export const DEMO_PASSWORD = '
|
|
3
|
+
export const DEMO_PASSWORD = 'shutro demo 2026';
|
|
4
4
|
const model = (env, name) => env.model(name);
|
|
5
5
|
/** Loads the demo data of the modules that have none yet. Returns a line per module that seeded something. */
|
|
6
6
|
export async function installDemo(options) {
|