shutro-erp 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +156 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +156 -1
- package/dist/assistant/index.js +1 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +156 -1
- package/dist/integrations/index.js +12 -0
- package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
- package/dist/integrations/web/OAuthAppsPage.js +79 -0
- package/dist/integrations/web/index.d.ts +1 -0
- package/dist/integrations/web/index.js +3 -0
- package/dist/integrations/web/messages.js +58 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +156 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/messaging/web/index.js +1 -1
- package/dist/notify/web/index.js +1 -1
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +157 -2
- package/dist/payment/bkash.d.ts +28 -0
- package/dist/payment/bkash.js +169 -0
- package/dist/payment/index.d.ts +4 -0
- package/dist/payment/index.js +80 -24
- package/dist/payment/module.d.ts +7 -4
- package/dist/payment/module.js +3 -0
- package/dist/payment/money.d.ts +7 -0
- package/dist/payment/money.js +37 -0
- package/dist/payment/providers.d.ts +13 -2
- package/dist/payment/razorpay.d.ts +12 -0
- package/dist/payment/razorpay.js +149 -0
- package/dist/payment/sslcommerz.js +1 -1
- package/dist/payment/stripe.d.ts +15 -0
- package/dist/payment/stripe.js +182 -0
- package/dist/permissions/admin.d.ts +3 -0
- package/dist/permissions/admin.js +108 -10
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.js +3 -0
- package/dist/permissions/roles.d.ts +2 -0
- package/dist/permissions/roles.js +236 -0
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +156 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +156 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +156 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +156 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +156 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +156 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +156 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +156 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +156 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +156 -1
- package/dist/server/auth/base.js +98 -0
- package/dist/server/auth/oauth.d.ts +21 -0
- package/dist/server/auth/oauth.js +579 -0
- package/dist/server/auth/routes.d.ts +3 -0
- package/dist/server/auth/routes.js +29 -0
- package/dist/server/auth/service.d.ts +56 -0
- package/dist/server/auth/service.js +185 -3
- package/dist/server/identity.d.ts +4 -0
- package/dist/server/images.d.ts +12 -0
- package/dist/server/images.js +139 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +1 -0
- package/dist/server/server.d.ts +4 -0
- package/dist/server/server.js +20 -4
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +189 -0
- package/dist/settings/index.d.ts +10 -10
- package/dist/settings/index.js +159 -30
- package/dist/settings/people.d.ts +16 -0
- package/dist/settings/people.js +507 -0
- package/dist/settings/security.d.ts +13 -0
- package/dist/settings/security.js +158 -0
- package/dist/settings/web/AuditPage.d.ts +8 -0
- package/dist/settings/web/AuditPage.js +74 -0
- package/dist/settings/web/CompaniesPage.d.ts +8 -0
- package/dist/settings/web/CompaniesPage.js +52 -0
- package/dist/settings/web/CompanyPage.d.ts +9 -0
- package/dist/settings/web/CompanyPage.js +105 -0
- package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
- package/dist/settings/web/ConnectedAppsPage.js +33 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +77 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +154 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +196 -0
- package/dist/settings/web/RolesEditor.d.ts +35 -0
- package/dist/settings/web/RolesEditor.js +49 -0
- package/dist/settings/web/RolesPage.d.ts +9 -0
- package/dist/settings/web/RolesPage.js +296 -0
- package/dist/settings/web/SecurityPage.d.ts +8 -0
- package/dist/settings/web/SecurityPage.js +81 -0
- package/dist/settings/web/index.d.ts +10 -0
- package/dist/settings/web/index.js +31 -1
- package/dist/settings/web/messages.js +594 -0
- package/dist/ui/api.d.ts +8 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +143 -33
- package/dist/ui/components/AppIcon.js +11 -1
- package/dist/ui/components/Avatar.d.ts +21 -0
- package/dist/ui/components/Avatar.js +71 -0
- package/dist/ui/components/primitives.d.ts +9 -1
- package/dist/ui/components/primitives.js +12 -4
- package/dist/ui/context.d.ts +10 -1
- package/dist/ui/context.js +60 -2
- package/dist/ui/i18n/messages.d.ts +76 -0
- package/dist/ui/i18n/messages.js +152 -0
- package/dist/ui/index.d.ts +3 -1
- package/dist/ui/index.js +3 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -0
- package/dist/ui/registry.d.ts +7 -1
- package/dist/ui/registry.js +10 -1
- package/dist/ui/search/ViewTabs.d.ts +14 -0
- package/dist/ui/search/ViewTabs.js +42 -0
- package/dist/ui/shell/Activate.d.ts +6 -0
- package/dist/ui/shell/Activate.js +39 -0
- package/dist/ui/shell/CommandPalette.d.ts +7 -1
- package/dist/ui/shell/CommandPalette.js +16 -2
- package/dist/ui/shell/Consent.d.ts +5 -0
- package/dist/ui/shell/Consent.js +35 -0
- package/dist/ui/shell/Home.js +85 -16
- package/dist/ui/shell/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +77 -0
- package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
- package/dist/ui/shell/TwoFactorSetup.js +32 -0
- package/dist/ui/shell/shortcuts.d.ts +3 -0
- package/dist/ui/shell/shortcuts.js +13 -4
- package/dist/ui/styles.css +70 -28
- package/dist/ui/views/DateField.d.ts +10 -0
- package/dist/ui/views/DateField.js +129 -0
- package/dist/ui/views/FormView.js +161 -21
- package/dist/ui/views/ListView.d.ts +2 -0
- package/dist/ui/views/ListView.js +57 -25
- package/dist/ui/views/StatusChip.d.ts +10 -0
- package/dist/ui/views/StatusChip.js +22 -0
- package/dist/ui/views/widgets.d.ts +3 -0
- package/dist/ui/views/widgets.js +26 -8
- package/dist/views/types.d.ts +7 -0
- package/dist/views/validate.js +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe adapter (RFC 0017 §5.5), on Checkout Sessions: a session per transaction (`client_reference_id` and
|
|
3
|
+
* `metadata.reference` = its reference, one line of the invoice's amount in subunits) whose page the customer pays
|
|
4
|
+
* on, back to the return route. Stripe's webhook is set up in its dashboard with the provider's endpoint URL; Stripe
|
|
5
|
+
* gives the signing secret (`whsec_…`), which goes into **the endpoint's Secret**. `Stripe-Signature`
|
|
6
|
+
* (`t=…,v1=…`: HMAC-SHA256 hex of `"<t>.<raw body>"`) is checked with it, and old timestamps are refused (5 minutes)
|
|
7
|
+
* against replays; the event's `id` makes deliveries idempotent. Returns fetch the session from Stripe.
|
|
8
|
+
*
|
|
9
|
+
* Credentials come from the environment, never from records: `SHUTRO_ERP_STRIPE_SECRET_KEY`. Test keys
|
|
10
|
+
* (`sk_test_…`, `rk_test_…`) serve providers in test mode, live keys live ones.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import { fromSubunits, toSubunits } from './money.js';
|
|
14
|
+
const text = (v) => (v === undefined || v === null ? '' : String(v));
|
|
15
|
+
/** What a Checkout Session says of its payment. */
|
|
16
|
+
function sessionResult(reference, session, kind) {
|
|
17
|
+
const theirs = text(session['client_reference_id']) ||
|
|
18
|
+
text(session['metadata']?.['reference']);
|
|
19
|
+
if (theirs !== reference)
|
|
20
|
+
return null;
|
|
21
|
+
const currency = text(session['currency']).toUpperCase();
|
|
22
|
+
const intent = session['payment_intent'];
|
|
23
|
+
const providerReference = (typeof intent === 'string' ? intent : text(intent?.['id'])) ||
|
|
24
|
+
text(session['id']) ||
|
|
25
|
+
null;
|
|
26
|
+
// A delayed method (a bank debit) that did not go through.
|
|
27
|
+
if (kind === 'checkout.session.async_payment_failed')
|
|
28
|
+
return {
|
|
29
|
+
reference,
|
|
30
|
+
status: 'failed',
|
|
31
|
+
providerReference,
|
|
32
|
+
error: 'The payment failed at Stripe.',
|
|
33
|
+
};
|
|
34
|
+
if (text(session['payment_status']) === 'paid')
|
|
35
|
+
return {
|
|
36
|
+
reference,
|
|
37
|
+
status: 'done',
|
|
38
|
+
providerReference,
|
|
39
|
+
amount: fromSubunits(Number(session['amount_total'] ?? 0), currency),
|
|
40
|
+
currency,
|
|
41
|
+
};
|
|
42
|
+
if (text(session['status']) === 'expired' || kind === 'checkout.session.expired')
|
|
43
|
+
return { reference, status: 'cancelled', providerReference };
|
|
44
|
+
return { reference, status: 'pending', providerReference };
|
|
45
|
+
}
|
|
46
|
+
/** Stripe's form encoding of nested parameters (`line_items[0][price_data][currency]=bdt`). */
|
|
47
|
+
function form(params, prefix = '', out = new URLSearchParams()) {
|
|
48
|
+
for (const [key, value] of Object.entries(params)) {
|
|
49
|
+
const name = prefix === '' ? key : `${prefix}[${key}]`;
|
|
50
|
+
if (value === undefined || value === null)
|
|
51
|
+
continue;
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
value.forEach((v, i) => {
|
|
54
|
+
if (typeof v === 'object' && v !== null)
|
|
55
|
+
form(v, `${name}[${i}]`, out);
|
|
56
|
+
else
|
|
57
|
+
out.append(`${name}[${i}]`, String(v));
|
|
58
|
+
});
|
|
59
|
+
else if (typeof value === 'object')
|
|
60
|
+
form(value, name, out);
|
|
61
|
+
else
|
|
62
|
+
out.append(name, String(value));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
export function stripeAdapter(options = {}) {
|
|
67
|
+
const key = options.secretKey ?? '';
|
|
68
|
+
const base = (options.baseUrl || 'https://api.stripe.com/v1').replace(/\/$/, '');
|
|
69
|
+
const http = options.fetch ?? fetch;
|
|
70
|
+
const tolerance = options.tolerance ?? 300;
|
|
71
|
+
const now = options.now ?? (() => Date.now());
|
|
72
|
+
const ready = /^(sk|rk)_(test|live)_/.test(key);
|
|
73
|
+
const call = async (method, path, params) => {
|
|
74
|
+
if (!ready)
|
|
75
|
+
throw new Error('Stripe is not configured: set SHUTRO_ERP_STRIPE_SECRET_KEY.');
|
|
76
|
+
const res = await http(`${base}${path}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers: {
|
|
79
|
+
authorization: `Bearer ${key}`,
|
|
80
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
81
|
+
},
|
|
82
|
+
...(params === undefined ? {} : { body: form(params) }),
|
|
83
|
+
signal: AbortSignal.timeout(30_000),
|
|
84
|
+
});
|
|
85
|
+
const answer = (await res.json().catch(() => ({})));
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const error = (answer['error'] ?? {});
|
|
88
|
+
throw new Error(`Stripe answered ${res.status}: ${text(error['message']) || 'no detail'}.`);
|
|
89
|
+
}
|
|
90
|
+
return answer;
|
|
91
|
+
};
|
|
92
|
+
const session = (id) => call('GET', `/checkout/sessions/${encodeURIComponent(id)}`);
|
|
93
|
+
return {
|
|
94
|
+
code: 'stripe',
|
|
95
|
+
configured: (mode) => ready && key.includes(mode === 'test' ? '_test_' : '_live_'),
|
|
96
|
+
async createPayment(request) {
|
|
97
|
+
const made = await call('POST', '/checkout/sessions', {
|
|
98
|
+
mode: 'payment',
|
|
99
|
+
client_reference_id: request.reference,
|
|
100
|
+
success_url: request.returnUrl,
|
|
101
|
+
cancel_url: request.returnUrl,
|
|
102
|
+
...(request.customer.email === null ? {} : { customer_email: request.customer.email }),
|
|
103
|
+
line_items: [
|
|
104
|
+
{
|
|
105
|
+
quantity: 1,
|
|
106
|
+
price_data: {
|
|
107
|
+
currency: request.currency.toLowerCase(),
|
|
108
|
+
unit_amount: toSubunits(request.amount, request.currency),
|
|
109
|
+
product_data: { name: request.description },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
metadata: { reference: request.reference },
|
|
114
|
+
payment_intent_data: { metadata: { reference: request.reference } },
|
|
115
|
+
});
|
|
116
|
+
const id = text(made['id']);
|
|
117
|
+
const page = text(made['url']);
|
|
118
|
+
if (id === '' || page === '')
|
|
119
|
+
throw new Error('Stripe made no Checkout Session.');
|
|
120
|
+
return { redirectUrl: page, providerReference: id };
|
|
121
|
+
},
|
|
122
|
+
verifyCallback(call, secret) {
|
|
123
|
+
const header = call.headers['stripe-signature'] ?? '';
|
|
124
|
+
const parts = header.split(',').map((p) => p.split('='));
|
|
125
|
+
const t = Number(parts.find(([k]) => k === 't')?.[1]);
|
|
126
|
+
if (!Number.isFinite(t) || Math.abs(now() / 1000 - t) > tolerance)
|
|
127
|
+
return null;
|
|
128
|
+
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${call.body}`).digest('hex'));
|
|
129
|
+
const genuine = parts
|
|
130
|
+
.filter(([k]) => k === 'v1')
|
|
131
|
+
.some(([, v]) => {
|
|
132
|
+
const given = Buffer.from(v ?? '');
|
|
133
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
134
|
+
});
|
|
135
|
+
if (!genuine)
|
|
136
|
+
return null;
|
|
137
|
+
const id = text(call.json?.['id']);
|
|
138
|
+
return id !== '' ? id : null;
|
|
139
|
+
},
|
|
140
|
+
readCallback(call) {
|
|
141
|
+
const event = call.json;
|
|
142
|
+
const kind = text(event?.['type']);
|
|
143
|
+
if (!kind.startsWith('checkout.session.'))
|
|
144
|
+
return null;
|
|
145
|
+
const object = (event?.['data']?.['object'] ?? null);
|
|
146
|
+
if (object === null)
|
|
147
|
+
return null;
|
|
148
|
+
const reference = text(object['client_reference_id']) ||
|
|
149
|
+
text(object['metadata']?.['reference']);
|
|
150
|
+
const result = sessionResult(reference, object, kind);
|
|
151
|
+
return result === null || result.status === 'pending' ? null : result;
|
|
152
|
+
},
|
|
153
|
+
async returned({ reference, providerReference }) {
|
|
154
|
+
if (providerReference === null || providerReference === '')
|
|
155
|
+
return null;
|
|
156
|
+
const result = sessionResult(reference, await session(providerReference));
|
|
157
|
+
return result?.status === 'pending' ? null : result;
|
|
158
|
+
},
|
|
159
|
+
async status(reference, _mode, providerReference) {
|
|
160
|
+
if (!ready || providerReference === null || providerReference === '')
|
|
161
|
+
return null;
|
|
162
|
+
return sessionResult(reference, await session(providerReference));
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Stripe with its key from the environment, read on first use. */
|
|
167
|
+
export function stripeFromEnvironment(env = process.env) {
|
|
168
|
+
let made;
|
|
169
|
+
const real = () => (made ??= stripeAdapter({
|
|
170
|
+
secretKey: env['KOSH_STRIPE_SECRET_KEY'],
|
|
171
|
+
baseUrl: env['KOSH_STRIPE_BASE_URL'],
|
|
172
|
+
}));
|
|
173
|
+
return {
|
|
174
|
+
code: 'stripe',
|
|
175
|
+
configured: (mode) => real().configured(mode),
|
|
176
|
+
createPayment: (request) => real().createPayment(request),
|
|
177
|
+
verifyCallback: (call, secret) => real().verifyCallback(call, secret),
|
|
178
|
+
readCallback: (call) => real().readCallback(call),
|
|
179
|
+
returned: (input) => real().returned?.(input) ?? Promise.resolve(null),
|
|
180
|
+
status: (reference, mode, providerReference) => real().status(reference, mode, providerReference),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
* Approval policies are data: administrators edit `approval.policy` / `approval.level` through the generated API.
|
|
10
10
|
*/
|
|
11
11
|
import { type ModuleContext } from '../server/index.js';
|
|
12
|
+
import type { Env } from '../server/orm.js';
|
|
13
|
+
/** Refuses non-administrators (or, with `permission`, users without it). */
|
|
14
|
+
export declare function requireAdministrator(env: Env, permission?: string): void;
|
|
12
15
|
/** Registers `/api/v1/admin/*`. */
|
|
13
16
|
export declare function registerAdminApi(context: ModuleContext): void;
|
|
@@ -43,7 +43,7 @@ function idParam(request, key = 'id') {
|
|
|
43
43
|
return id;
|
|
44
44
|
}
|
|
45
45
|
/** Refuses non-administrators (or, with `permission`, users without it). */
|
|
46
|
-
function requireAdministrator(env, permission) {
|
|
46
|
+
export function requireAdministrator(env, permission) {
|
|
47
47
|
if (env.administrator)
|
|
48
48
|
return;
|
|
49
49
|
if (permission !== undefined && env.access.has(permission))
|
|
@@ -99,6 +99,10 @@ export function registerAdminApi(context) {
|
|
|
99
99
|
params: [],
|
|
100
100
|
});
|
|
101
101
|
const admins = env.access.administratorGroups();
|
|
102
|
+
// How many people hold each role (administrators read the assignments).
|
|
103
|
+
const holders = new Map();
|
|
104
|
+
for (const a of await find(env, 'auth.user_group', {}, ['user', 'group']))
|
|
105
|
+
holders.set(String(a['group']), (holders.get(String(a['group'])) ?? new Set()).add(a['user']));
|
|
102
106
|
const levelsOf = (implies) => {
|
|
103
107
|
const closure = reg.security.closure(implies.filter((c) => reg.security.groups.has(c)));
|
|
104
108
|
return [...loaded.accessLevels.entries()]
|
|
@@ -114,6 +118,7 @@ export function registerAdminApi(context) {
|
|
|
114
118
|
administrator: admins.has(g.code),
|
|
115
119
|
builtIn: true,
|
|
116
120
|
accessLevels: levelsOf(g.implies),
|
|
121
|
+
holders: holders.get(g.code)?.size ?? 0,
|
|
117
122
|
})),
|
|
118
123
|
...runtime.map((r) => ({
|
|
119
124
|
code: r.code,
|
|
@@ -122,6 +127,7 @@ export function registerAdminApi(context) {
|
|
|
122
127
|
administrator: admins.has(r.code),
|
|
123
128
|
builtIn: false,
|
|
124
129
|
accessLevels: levelsOf(r.implies),
|
|
130
|
+
holders: holders.get(r.code)?.size ?? 0,
|
|
125
131
|
})),
|
|
126
132
|
],
|
|
127
133
|
};
|
|
@@ -342,6 +348,7 @@ export function registerAdminApi(context) {
|
|
|
342
348
|
models: Object.fromEntries(models.map((name) => [
|
|
343
349
|
name,
|
|
344
350
|
{
|
|
351
|
+
label: reg.get(name).model.label ?? name,
|
|
345
352
|
perms: PERMS.filter((p) => env.access.can(name, p)),
|
|
346
353
|
hiddenFields: Object.keys(reg.get(name).model.fields).filter((k) => !env.access.canField(name, k, 'read')),
|
|
347
354
|
readonlyFields: Object.keys(reg.get(name).model.fields).filter((k) => env.access.canField(name, k, 'read') &&
|
|
@@ -351,25 +358,67 @@ export function registerAdminApi(context) {
|
|
|
351
358
|
permissions: [...reg.security.permissions.keys()]
|
|
352
359
|
.filter((code) => env.access.has(code))
|
|
353
360
|
.sort(),
|
|
361
|
+
/** The permissions' names, for screens. */
|
|
362
|
+
permissionLabels: Object.fromEntries([...reg.security.permissions.values()].map((p) => [p.code, p.label ?? p.code])),
|
|
354
363
|
};
|
|
355
364
|
});
|
|
356
365
|
});
|
|
357
366
|
const hidden = new Set(loaded.modules.flatMap((m) => m.privateModels ?? []));
|
|
358
367
|
const privateModel = (name) => hidden.has(name);
|
|
359
368
|
// ------------------------------------------------------------------------------------------- audit log
|
|
360
|
-
|
|
369
|
+
// Filters (RFC 0019 §3.5): `kind` (`auth.` for a family), `actor` (a user id), `model` and `record` (the target),
|
|
370
|
+
// `from` / `to` (ISO dates, `to` inclusive of its day), `q` (words of the summary), `before` (an id: the next page);
|
|
371
|
+
// `format=csv` downloads the page as CSV — an export, itself audited.
|
|
372
|
+
app.get('/api/v1/admin/audit', async (request, reply) => {
|
|
361
373
|
const q = (request.query ?? {});
|
|
362
|
-
const limit = q
|
|
374
|
+
const limit = q['limit'] === undefined ? 100 : Number(q['limit']);
|
|
363
375
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
364
376
|
throw bad('limit must be 1–1000.');
|
|
365
|
-
|
|
377
|
+
const where = {};
|
|
378
|
+
const text = (key) => typeof q[key] === 'string' && q[key] !== '' ? q[key] : undefined;
|
|
379
|
+
const id = (key) => {
|
|
380
|
+
const v = text(key);
|
|
381
|
+
if (v === undefined)
|
|
382
|
+
return undefined;
|
|
383
|
+
const n = Number(v);
|
|
384
|
+
if (!Number.isSafeInteger(n) || n <= 0)
|
|
385
|
+
throw bad(`${key} is a positive integer.`);
|
|
386
|
+
return n;
|
|
387
|
+
};
|
|
388
|
+
const date = (key) => {
|
|
389
|
+
const v = text(key);
|
|
390
|
+
if (v === undefined)
|
|
391
|
+
return undefined;
|
|
392
|
+
const d = new Date(v);
|
|
393
|
+
if (Number.isNaN(d.getTime()))
|
|
394
|
+
throw bad(`${key} is an ISO date.`);
|
|
395
|
+
return d;
|
|
396
|
+
};
|
|
397
|
+
const kind = text('kind');
|
|
398
|
+
if (kind !== undefined)
|
|
399
|
+
where['kind'] = kind.endsWith('.') ? { like: `${kind}%` } : kind;
|
|
400
|
+
if (id('actor') !== undefined)
|
|
401
|
+
where['actor'] = id('actor');
|
|
402
|
+
if (text('model') !== undefined)
|
|
403
|
+
where['targetModel'] = text('model');
|
|
404
|
+
if (id('record') !== undefined)
|
|
405
|
+
where['targetId'] = id('record');
|
|
406
|
+
if (text('q') !== undefined)
|
|
407
|
+
where['summary'] = { ilike: `%${text('q')}%` };
|
|
408
|
+
const from = date('from');
|
|
409
|
+
const to = date('to');
|
|
410
|
+
if (from !== undefined || to !== undefined)
|
|
411
|
+
where['createdAt'] = {
|
|
412
|
+
...(from === undefined ? {} : { gte: from }),
|
|
413
|
+
// `to` is a day: everything before the next one.
|
|
414
|
+
...(to === undefined ? {} : { lt: new Date(to.getTime() + 86_400_000) }),
|
|
415
|
+
};
|
|
416
|
+
if (id('before') !== undefined)
|
|
417
|
+
where['id'] = { lt: id('before') };
|
|
418
|
+
const csv = q['format'] === 'csv';
|
|
419
|
+
const events = await request.env(async (env) => {
|
|
366
420
|
requireAdministrator(env, 'permissions.view_audit');
|
|
367
421
|
return env.asSuperuser({ reason: 'read audit log' }, async (su) => {
|
|
368
|
-
const where = {};
|
|
369
|
-
if (typeof q.kind === 'string' && q.kind !== '')
|
|
370
|
-
where['kind'] = q.kind.endsWith('.') ? { like: `${q.kind}%` } : q.kind;
|
|
371
|
-
if (q.before !== undefined)
|
|
372
|
-
where['id'] = { lt: Number(q.before) };
|
|
373
422
|
const set = await ref(su, 'audit.event').search({ where, order: ['id desc'], limit });
|
|
374
423
|
const keys = [
|
|
375
424
|
'kind',
|
|
@@ -382,8 +431,57 @@ export function registerAdminApi(context) {
|
|
|
382
431
|
'ip',
|
|
383
432
|
'createdAt',
|
|
384
433
|
];
|
|
385
|
-
|
|
434
|
+
const found = set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
|
|
435
|
+
const actors = [
|
|
436
|
+
...new Set(found.map((e) => e['actor']).filter((a) => typeof a === 'number')),
|
|
437
|
+
];
|
|
438
|
+
const names = new Map(actors.length === 0
|
|
439
|
+
? []
|
|
440
|
+
: (await // Deactivated people keep their name in the log.
|
|
441
|
+
(await ref(su.withFlags({ activeTest: false }), 'res.users').search({
|
|
442
|
+
where: { id: { in: actors } },
|
|
443
|
+
})).load(['name']))
|
|
444
|
+
.read(['id', 'name'])
|
|
445
|
+
.map((u) => [u['id'], String(u['name'])]));
|
|
446
|
+
if (csv)
|
|
447
|
+
await audit(env, {
|
|
448
|
+
kind: 'data.export',
|
|
449
|
+
summary: `Exported ${found.length} audit event(s).`,
|
|
450
|
+
targetModel: 'audit.event',
|
|
451
|
+
details: { filters: where },
|
|
452
|
+
ip: request.ip,
|
|
453
|
+
});
|
|
454
|
+
return found.map((e) => ({
|
|
455
|
+
...e,
|
|
456
|
+
actorName: typeof e['actor'] === 'number' ? (names.get(e['actor']) ?? null) : null,
|
|
457
|
+
}));
|
|
386
458
|
});
|
|
387
459
|
}, { write: true }); // the system read of the log is itself audited
|
|
460
|
+
if (!csv)
|
|
461
|
+
return { events };
|
|
462
|
+
const cell = (v) => {
|
|
463
|
+
const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString() : String(v);
|
|
464
|
+
// Quoted, and never read as a formula by a spreadsheet.
|
|
465
|
+
return `"${(/^[=+\-@]/.test(s) ? `'${s}` : s).replaceAll('"', '""')}"`;
|
|
466
|
+
};
|
|
467
|
+
const columns = [
|
|
468
|
+
'id',
|
|
469
|
+
'createdAt',
|
|
470
|
+
'kind',
|
|
471
|
+
'actorName',
|
|
472
|
+
'summary',
|
|
473
|
+
'targetModel',
|
|
474
|
+
'targetId',
|
|
475
|
+
'ip',
|
|
476
|
+
];
|
|
477
|
+
const lines = [
|
|
478
|
+
columns.join(','),
|
|
479
|
+
...events.map((e) => columns.map((c) => cell(e[c])).join(',')),
|
|
480
|
+
];
|
|
481
|
+
// A byte-order mark so spreadsheets read UTF-8 (Bangla names).
|
|
482
|
+
return reply
|
|
483
|
+
.header('content-type', 'text/csv; charset=utf-8')
|
|
484
|
+
.header('content-disposition', 'attachment; filename="audit-log.csv"')
|
|
485
|
+
.send(`${lines.join('\r\n')}\r\n`);
|
|
388
486
|
});
|
|
389
487
|
}
|
|
@@ -8,6 +8,7 @@ import { defineModule } from '../modules-sdk/index.js';
|
|
|
8
8
|
export { registerAdminApi } from './admin.js';
|
|
9
9
|
export { type ApprovalRequestInfo, approvalGuard, approvalState, decide, inbox, installApprovalPolicies, requestApprovals, requestsOf, } from './approvals.js';
|
|
10
10
|
export { permissionsShutro } from './models.js';
|
|
11
|
+
export { registerRoleRoutes } from './roles.js';
|
|
11
12
|
export { registerApprovalRoutes } from './routes.js';
|
|
12
13
|
/** Package identity. */
|
|
13
14
|
export declare const PACKAGE: {
|
|
@@ -9,10 +9,12 @@ import { base } from '../server/index.js';
|
|
|
9
9
|
import { registerAdminApi } from './admin.js';
|
|
10
10
|
import { installApprovalPolicies } from './approvals.js';
|
|
11
11
|
import { permissionsShutro } from './models.js';
|
|
12
|
+
import { registerRoleRoutes } from './roles.js';
|
|
12
13
|
import { registerApprovalRoutes } from './routes.js';
|
|
13
14
|
export { registerAdminApi } from './admin.js';
|
|
14
15
|
export { approvalGuard, approvalState, decide, inbox, installApprovalPolicies, requestApprovals, requestsOf, } from './approvals.js';
|
|
15
16
|
export { permissionsShutro } from './models.js';
|
|
17
|
+
export { registerRoleRoutes } from './roles.js';
|
|
16
18
|
export { registerApprovalRoutes } from './routes.js';
|
|
17
19
|
/** Package identity. */
|
|
18
20
|
export const PACKAGE = Object.freeze({
|
|
@@ -36,6 +38,7 @@ export const permissions = defineModule({
|
|
|
36
38
|
},
|
|
37
39
|
routes: (context) => {
|
|
38
40
|
registerAdminApi(context);
|
|
41
|
+
registerRoleRoutes(context);
|
|
39
42
|
registerApprovalRoutes(context);
|
|
40
43
|
},
|
|
41
44
|
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { HttpProblem } from '../server/index.js';
|
|
2
|
+
import { requireAdministrator } from './admin.js';
|
|
3
|
+
const PERMS = ['read', 'write', 'create', 'unlink', 'export', 'import'];
|
|
4
|
+
const find = async (env, model, where, keys) => {
|
|
5
|
+
const set = (await env.model(model).search({ where, order: ['id asc'] }));
|
|
6
|
+
return set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
|
|
7
|
+
};
|
|
8
|
+
const bad = (detail) => new HttpProblem(400, 'invalid-body', 'Invalid request', detail);
|
|
9
|
+
export function registerRoleRoutes(context) {
|
|
10
|
+
const { app, registry } = context;
|
|
11
|
+
const reg = registry.registry;
|
|
12
|
+
const loaded = registry.loaded;
|
|
13
|
+
const modules = loaded.modules;
|
|
14
|
+
const hidden = new Set(modules.flatMap((m) => m.privateModels ?? []));
|
|
15
|
+
const appOf = new Map(modules.map((m) => [m.name, m.app?.label ?? null]));
|
|
16
|
+
/** Every group a set of roles amounts to: declared groups' closure, runtime roles followed through. */
|
|
17
|
+
const closureOf = (codes, runtime) => {
|
|
18
|
+
const roles = new Map(runtime.roles.map((r) => [r.code, r]));
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const queue = [...codes];
|
|
21
|
+
while (queue.length > 0) {
|
|
22
|
+
const code = queue.pop();
|
|
23
|
+
if (seen.has(code))
|
|
24
|
+
continue;
|
|
25
|
+
seen.add(code);
|
|
26
|
+
for (const next of roles.get(code)?.implies ?? [])
|
|
27
|
+
queue.push(next);
|
|
28
|
+
}
|
|
29
|
+
return new Set([
|
|
30
|
+
...seen,
|
|
31
|
+
...reg.security.closure([...seen].filter((c) => reg.security.groups.has(c))),
|
|
32
|
+
]);
|
|
33
|
+
};
|
|
34
|
+
/** What a set of groups grants: per model the permissions, and the feature permissions. */
|
|
35
|
+
const grants = (groups, runtime, except) => {
|
|
36
|
+
const models = new Map();
|
|
37
|
+
for (const [name, model] of reg.models) {
|
|
38
|
+
const access = (model.model.access ?? {});
|
|
39
|
+
const perms = new Set();
|
|
40
|
+
for (const g of groups)
|
|
41
|
+
for (const p of access[g] ?? [])
|
|
42
|
+
perms.add(p);
|
|
43
|
+
if (perms.size > 0)
|
|
44
|
+
models.set(name, perms);
|
|
45
|
+
}
|
|
46
|
+
for (const a of runtime.access)
|
|
47
|
+
if (groups.has(a.role) && a.role !== except)
|
|
48
|
+
for (const p of a.perms) {
|
|
49
|
+
const set = models.get(a.model) ?? new Set();
|
|
50
|
+
set.add(p);
|
|
51
|
+
models.set(a.model, set);
|
|
52
|
+
}
|
|
53
|
+
const permissions = new Set();
|
|
54
|
+
for (const p of reg.security.permissions.values())
|
|
55
|
+
if (p.groups.some((g) => groups.has(g)))
|
|
56
|
+
permissions.add(p.code);
|
|
57
|
+
for (const g of runtime.permissions)
|
|
58
|
+
if (groups.has(g.role) && g.role !== except)
|
|
59
|
+
permissions.add(g.permission);
|
|
60
|
+
const levels = new Set([...loaded.accessLevels.entries()]
|
|
61
|
+
.filter(([, level]) => level.groups.length > 0 && level.groups.every((g) => groups.has(g)))
|
|
62
|
+
.map(([code]) => code));
|
|
63
|
+
return { models, permissions, levels };
|
|
64
|
+
};
|
|
65
|
+
const levelLabel = (code) => {
|
|
66
|
+
const level = loaded.accessLevels.get(code);
|
|
67
|
+
return level === undefined
|
|
68
|
+
? code
|
|
69
|
+
: `${appOf.get(level.module) ?? level.module} · ${level.label}`;
|
|
70
|
+
};
|
|
71
|
+
const permissionLabel = (code) => reg.security.permissions.get(code)?.label ?? code;
|
|
72
|
+
/** The role, whether declared in code or made at runtime. */
|
|
73
|
+
const roleOf = (code, runtime) => {
|
|
74
|
+
const declared = reg.security.groups.get(code);
|
|
75
|
+
if (declared !== undefined)
|
|
76
|
+
return {
|
|
77
|
+
code,
|
|
78
|
+
label: declared.label ?? code,
|
|
79
|
+
implies: [...(declared.implies ?? [])],
|
|
80
|
+
builtIn: true,
|
|
81
|
+
};
|
|
82
|
+
const made = runtime.roles.find((r) => r.code === code);
|
|
83
|
+
if (made === undefined)
|
|
84
|
+
throw new HttpProblem(404, 'missing-record', 'Not found', `There is no role '${code}'.`);
|
|
85
|
+
return { code, label: made.label, implies: [...made.implies], builtIn: false };
|
|
86
|
+
};
|
|
87
|
+
const holdersOf = async (env, code) => env.asSuperuser({ reason: 'role holders' }, async (su) => {
|
|
88
|
+
const assigned = await find(su, 'auth.user_group', { group: code }, ['user']);
|
|
89
|
+
const ids = [...new Set(assigned.map((a) => a['user']))];
|
|
90
|
+
if (ids.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
return (await find(su, 'res.users', { id: { in: ids } }, ['name', 'login'])).map((u) => ({
|
|
93
|
+
id: u['id'],
|
|
94
|
+
name: String(u['name']),
|
|
95
|
+
login: String(u['login']),
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
const codeParam = (request) => String(request.params['code'] ?? '');
|
|
99
|
+
app.get('/api/v1/admin/roles/:code', async (request) => {
|
|
100
|
+
const code = codeParam(request);
|
|
101
|
+
return request.env(async (env) => {
|
|
102
|
+
requireAdministrator(env);
|
|
103
|
+
const runtime = await env.securityAdmin.describe();
|
|
104
|
+
const role = roleOf(code, runtime);
|
|
105
|
+
const closure = closureOf([code], runtime);
|
|
106
|
+
// Locked: what code and the included roles give; own: this role's runtime grants.
|
|
107
|
+
const locked = grants(closure, runtime, code);
|
|
108
|
+
const own = new Map(runtime.access.filter((a) => a.role === code).map((a) => [a.model, [...a.perms]]));
|
|
109
|
+
const ownPermissions = runtime.permissions
|
|
110
|
+
.filter((p) => p.role === code)
|
|
111
|
+
.map((p) => p.permission);
|
|
112
|
+
return {
|
|
113
|
+
...role,
|
|
114
|
+
administrator: env.access.administratorGroups().has(code),
|
|
115
|
+
accessLevels: [...grants(closureOf(role.implies, runtime), runtime).levels],
|
|
116
|
+
holders: await holdersOf(env, code),
|
|
117
|
+
models: [...reg.models]
|
|
118
|
+
.filter(([name]) => !hidden.has(name) && !name.startsWith('shutro.'))
|
|
119
|
+
.map(([name, model]) => ({
|
|
120
|
+
name,
|
|
121
|
+
label: model.model.label ?? name,
|
|
122
|
+
app: appOf.get(model.module) ?? null,
|
|
123
|
+
locked: [...(locked.models.get(name) ?? [])],
|
|
124
|
+
own: own.get(name) ?? [],
|
|
125
|
+
}))
|
|
126
|
+
// Apps first (by name), technical models last.
|
|
127
|
+
.sort((a, b) => Number(a.app === null) - Number(b.app === null) ||
|
|
128
|
+
(a.app ?? '').localeCompare(b.app ?? '') ||
|
|
129
|
+
a.label.localeCompare(b.label)),
|
|
130
|
+
permissions: [...reg.security.permissions.values()].map((p) => ({
|
|
131
|
+
code: p.code,
|
|
132
|
+
label: p.label ?? p.code,
|
|
133
|
+
locked: locked.permissions.has(p.code),
|
|
134
|
+
own: ownPermissions.includes(p.code),
|
|
135
|
+
})),
|
|
136
|
+
rules: runtime.rules.filter((r) => r.role === code),
|
|
137
|
+
fields: runtime.fields.filter((f) => f.read.includes(code) || f.write.includes(code)),
|
|
138
|
+
};
|
|
139
|
+
}, { write: true });
|
|
140
|
+
});
|
|
141
|
+
app.post('/api/v1/admin/roles/:code/preview', async (request) => {
|
|
142
|
+
const code = codeParam(request);
|
|
143
|
+
const body = (request.body ?? {});
|
|
144
|
+
const list = (v, what) => {
|
|
145
|
+
if (v === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
|
|
148
|
+
throw bad(`${what} is a list of codes.`);
|
|
149
|
+
return v;
|
|
150
|
+
};
|
|
151
|
+
const levels = list(body['accessLevels'], 'accessLevels');
|
|
152
|
+
const implies = list(body['implies'], 'implies');
|
|
153
|
+
const permissions = list(body['permissions'], 'permissions');
|
|
154
|
+
const access = body['access'];
|
|
155
|
+
if (access !== undefined &&
|
|
156
|
+
(typeof access !== 'object' || access === null || Array.isArray(access)))
|
|
157
|
+
throw bad('access is { model: [permissions] }.');
|
|
158
|
+
return request.env(async (env) => {
|
|
159
|
+
requireAdministrator(env);
|
|
160
|
+
const runtime = await env.securityAdmin.describe();
|
|
161
|
+
const role = roleOf(code, runtime);
|
|
162
|
+
// The role's proposed state, as a runtime overlay of itself.
|
|
163
|
+
const levelGroups = (levels ?? []).flatMap((l) => {
|
|
164
|
+
const level = loaded.accessLevels.get(l);
|
|
165
|
+
if (level === undefined)
|
|
166
|
+
throw bad(`'${l}' is not an access level.`);
|
|
167
|
+
return [...level.groups];
|
|
168
|
+
});
|
|
169
|
+
const proposedImplies = levels === undefined && implies === undefined
|
|
170
|
+
? role.implies
|
|
171
|
+
: [...new Set([...levelGroups, ...(implies ?? [])])];
|
|
172
|
+
const ownAccess = (access ??
|
|
173
|
+
Object.fromEntries(runtime.access.filter((a) => a.role === code).map((a) => [a.model, a.perms])));
|
|
174
|
+
const proposed = {
|
|
175
|
+
...runtime,
|
|
176
|
+
roles: role.builtIn
|
|
177
|
+
? runtime.roles
|
|
178
|
+
: runtime.roles.map((r) => (r.code === code ? { ...r, implies: proposedImplies } : r)),
|
|
179
|
+
access: [
|
|
180
|
+
...runtime.access.filter((a) => a.role !== code),
|
|
181
|
+
...Object.entries(ownAccess).map(([model, perms]) => ({
|
|
182
|
+
role: code,
|
|
183
|
+
model,
|
|
184
|
+
perms: perms,
|
|
185
|
+
})),
|
|
186
|
+
],
|
|
187
|
+
permissions: permissions === undefined
|
|
188
|
+
? runtime.permissions
|
|
189
|
+
: [
|
|
190
|
+
...runtime.permissions.filter((p) => p.role !== code),
|
|
191
|
+
...permissions.map((permission) => ({ role: code, permission })),
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
const before = grants(closureOf([code], runtime), runtime);
|
|
195
|
+
const after = grants(closureOf([code], proposed), proposed);
|
|
196
|
+
const holders = await holdersOf(env, code);
|
|
197
|
+
const others = await env.asSuperuser({ reason: 'role preview' }, (su) => find(su, 'auth.user_group', { user: { in: holders.map((h) => h.id) } }, [
|
|
198
|
+
'user',
|
|
199
|
+
'group',
|
|
200
|
+
]));
|
|
201
|
+
const people = holders
|
|
202
|
+
.map((h) => {
|
|
203
|
+
const elsewhere = grants(closureOf(others
|
|
204
|
+
.filter((o) => o['user'] === h.id && o['group'] !== code)
|
|
205
|
+
.map((o) => String(o['group'])), runtime), runtime);
|
|
206
|
+
const diff = (from, to, kept) => [...to].filter((x) => !from.has(x) && !kept.has(x));
|
|
207
|
+
return {
|
|
208
|
+
id: h.id,
|
|
209
|
+
name: h.name,
|
|
210
|
+
gains: [
|
|
211
|
+
...diff(before.levels, after.levels, elsewhere.levels).map(levelLabel),
|
|
212
|
+
...diff(before.permissions, after.permissions, elsewhere.permissions).map(permissionLabel),
|
|
213
|
+
],
|
|
214
|
+
losses: [
|
|
215
|
+
...diff(after.levels, before.levels, elsewhere.levels).map(levelLabel),
|
|
216
|
+
...diff(after.permissions, before.permissions, elsewhere.permissions).map(permissionLabel),
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
})
|
|
220
|
+
.filter((p) => p.gains.length > 0 || p.losses.length > 0);
|
|
221
|
+
const models = [...new Set([...before.models.keys(), ...after.models.keys()])]
|
|
222
|
+
.map((name) => {
|
|
223
|
+
const was = before.models.get(name) ?? new Set();
|
|
224
|
+
const will = after.models.get(name) ?? new Set();
|
|
225
|
+
return {
|
|
226
|
+
model: name,
|
|
227
|
+
label: reg.models.get(name)?.model?.label ?? name,
|
|
228
|
+
added: PERMS.filter((p) => will.has(p) && !was.has(p)),
|
|
229
|
+
removed: PERMS.filter((p) => was.has(p) && !will.has(p)),
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
.filter((m) => m.added.length > 0 || m.removed.length > 0);
|
|
233
|
+
return { holders: holders.length, people, models };
|
|
234
|
+
}, { write: true });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash:
|
|
1
|
+
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: a4d290319c174316
|
|
2
2
|
// Bindings of module `portal`: import the definition API from here to get types scoped to what it can see.
|
|
3
3
|
export { defineModel, defineModule, extendModel, f, replace } from '../../server/orm.js';
|
|
4
4
|
import { definitions } from '../../server/orm.js';
|