-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathresolve-execution-context.ts
More file actions
411 lines (380 loc) · 16.7 KB
/
Copy pathresolve-execution-context.ts
File metadata and controls
411 lines (380 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* resolveExecutionContext — REST entry-point identity resolver.
*
* Builds an {@link ExecutionContext} from an incoming HTTP request by combining:
* - better-auth Bearer/Session cookies (`authService.api.getSession`)
* - API Key headers (`X-API-Key` / `Authorization: ApiKey <token>`) — a
* hand-rolled check that hashes the inbound key and looks it up against the
* `sys_api_key` system object by its at-rest hash, rejecting revoked or
* expired keys. (better-auth 1.6.x ships no apiKey plugin.)
* - `sys_member` lookup for `(userId, activeOrganizationId)` to populate
* organization-scoped roles, plus any extra permission sets bound through
* the `sys_user_permission_set` / `sys_role_permission_set` link tables.
*
* The resolver is intentionally non-fatal: when auth is not wired up or any
* of the dependent services are unavailable, it returns the partial context
* that can be reconstructed (even an empty `{ isSystem: false, roles: [],
* permissions: [] }`). Permission enforcement is the SecurityPlugin's job.
*/
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { resolveApiKeyPrincipal } from './api-key.js';
interface ResolveOptions {
/** Function returning a service from the active kernel (or undefined). */
getService: (name: string) => Promise<any> | any;
/** Function returning the data engine (ObjectQL) for the active scope. */
getQl: () => Promise<any> | any;
/** The raw incoming HTTP request (Fetch Request, Node IncomingMessage, …). */
request: any;
}
/**
* Convert the dispatcher's plain `Record<string,string>` headers map into
* a Web `Headers` instance so libraries like better-auth (which reads via
* `headers.get('cookie')`) work uniformly.
*/
function toHeaders(input: any): any {
if (!input) return new Headers();
if (typeof Headers !== 'undefined' && input instanceof Headers) return input;
const h = new Headers();
if (typeof input.entries === 'function') {
for (const [k, v] of input.entries()) h.set(String(k), String(v));
return h;
}
for (const k of Object.keys(input)) {
const v = (input as any)[k];
if (v == null) continue;
h.set(String(k), Array.isArray(v) ? v.join(',') : String(v));
}
return h;
}
function safeJsonParse<T>(s: string, fallback: T): T {
try { return JSON.parse(s) as T; } catch { return fallback; }
}
async function tryFind(ql: any, object: string, where: any, limit = 100): Promise<any[]> {
if (!ql || typeof ql.find !== 'function') return [];
try {
let rows = await ql.find(object, { where, limit, context: { isSystem: true } } as any);
if (rows && (rows as any).value) rows = (rows as any).value;
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
/** True for a valid IANA timezone name (e.g. `America/New_York`, `UTC`). */
function isValidTimeZone(tz: string): boolean {
try {
new Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
}
/** Coerce a stored preference/setting value to a valid IANA zone, or undefined. */
function coerceTimeZone(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : '';
return s && isValidTimeZone(s) ? s : undefined;
}
/** Coerce a stored locale value to a non-empty BCP-47-ish string, or undefined. */
function coerceLocale(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : '';
return s || undefined;
}
/** Coerce a stored currency value to an ISO 4217 (3-letter) code, or undefined. */
function coerceCurrency(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim().toUpperCase() : '';
return /^[A-Z]{3}$/.test(s) ? s : undefined;
}
/**
* Resolve the workspace localization defaults onto the ExecutionContext
* (ADR-0053 Phase 2): reference `timezone` and `locale`.
*
* Canonical path is the `localization` SettingsManifest via the `settings`
* service, whose cascade is platform default → global → tenant (ADR-0002: one
* org per physical tenant; per-user overrides are intentionally out of scope
* for v1). When the settings service or its namespace is unavailable (e.g. a
* minimal deployment), fall back to a direct tenant-scoped `sys_setting` read,
* then to the built-ins `UTC` / `en-US`.
*
* Every read is defensive — localization never blocks auth, and an invalid
* zone falls through to the built-in.
*/
async function resolveLocalization(
opts: ResolveOptions,
ql: any,
sctx: { tenantId?: string; userId?: string },
): Promise<{ timezone: string; locale: string; currency?: string }> {
// 1. Canonical — the `localization` manifest via the settings service.
try {
const settings: any = await opts.getService('settings');
if (settings && typeof settings.get === 'function') {
const [tzRes, localeRes, currencyRes] = await Promise.all([
settings.get('localization', 'timezone', sctx).catch(() => undefined),
settings.get('localization', 'locale', sctx).catch(() => undefined),
settings.get('localization', 'currency', sctx).catch(() => undefined),
]);
const tz = coerceTimeZone(tzRes?.value);
const locale = coerceLocale(localeRes?.value);
const currency = coerceCurrency(currencyRes?.value);
// A resolved value (incl. the manifest default) means the namespace is
// live — trust it and skip the legacy direct read.
if (tz || locale || currency) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency };
}
} catch {
// settings service unavailable → fall through to the direct read
}
// 2. Fallback — direct tenant-scoped `sys_setting` rows (no settings service
// registered, or namespace not loaded).
const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1);
const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1);
const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1);
return {
timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC',
locale: coerceLocale(localeRows[0]?.value) ?? 'en-US',
currency: coerceCurrency(currencyRows[0]?.value),
};
}
/**
* Resolve the {@link ExecutionContext} for an inbound request.
*
* Always resolves — never throws. Anonymous requests yield
* `{ isSystem: false, roles: [], permissions: [] }`.
*/
export async function resolveExecutionContext(opts: ResolveOptions): Promise<ExecutionContext> {
const headers = opts.request?.headers;
const ctx: ExecutionContext = {
roles: [],
permissions: [],
systemPermissions: [],
isSystem: false,
};
let userId: string | undefined;
let tenantId: string | undefined;
// 1. API Key path — takes precedence over session, since callers explicitly
// opt in to API-key auth via the header.
//
// better-auth 1.6.x ships no apiKey plugin, so this is a hand-rolled
// check: hash the inbound key, look it up against `sys_api_key` by the
// at-rest hash, and reject revoked or expired keys. The raw key is never
// stored or logged. Once resolved, the principal flows through the exact
// same role/permission/RLS resolution as the session path below.
// Verification is delegated to the shared `resolveApiKeyPrincipal`
// (@objectstack/core), the SAME verifier the REST data API uses, so the
// two surfaces never drift.
const keyPrincipal = await resolveApiKeyPrincipal(await opts.getQl(), headers);
if (keyPrincipal) {
userId = keyPrincipal.userId;
tenantId = keyPrincipal.tenantId;
for (const scope of keyPrincipal.scopes) {
if (!ctx.permissions!.includes(scope)) ctx.permissions!.push(scope);
}
}
// 2. Session / Bearer path — fall back when API key did not resolve a user.
if (!userId) {
try {
const authService: any = await opts.getService('auth');
// The auth service surfaces its better-auth API either as `.api`
// (legacy direct mount) or via `await getApi()` (lazy plugin).
// Try both so we don't silently degrade to anonymous when the
// shape differs across plugin versions.
let api: any = authService?.api;
if (!api && typeof authService?.getApi === 'function') {
api = await authService.getApi();
}
const headersInstance = toHeaders(headers);
const sessionData = await api?.getSession?.({ headers: headersInstance });
userId = sessionData?.user?.id ?? sessionData?.session?.userId;
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
// Session carries the user's email for free → use it for RLS owner-by-email.
if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
} catch {
// no auth configured — return anonymous context
}
}
if (userId) ctx.userId = userId;
if (tenantId) ctx.tenantId = tenantId;
if (!userId) return ctx;
// 3. Resolve organization-scoped roles via sys_member, then merge any
// permission sets bound via the link tables. All lookups go through
// ObjectQL with `isSystem: true` to avoid recursion through the
// SecurityPlugin middleware.
const ql = await opts.getQl();
if (!ql) return ctx;
// Resolve the caller's unique email for `current_user.email` RLS policies when
// the session path didn't supply it (e.g. API-key auth). One bounded lookup;
// email is the auth-unique owner anchor (display name is never used — it
// collides, and a collision on an ownership predicate leaks access).
if (!ctx.email) {
const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
if (userRows[0]?.email) ctx.email = String(userRows[0].email);
}
const memberWhere: any = tenantId
? { user_id: userId, organization_id: tenantId }
: { user_id: userId };
const members = await tryFind(ql, 'sys_member', memberWhere, 50);
for (const m of members) {
if (m.role && typeof m.role === 'string') {
// better-auth stores comma-separated roles for multi-role membership.
for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) {
if (!ctx.roles!.includes(r)) ctx.roles!.push(r);
}
}
}
// [ADR-0057 D4] Platform-owned RBAC role assignments — the source of truth,
// decoupled from better-auth's `sys_member.role` (which is being reframed to
// org-administration: owner/admin/member). Merged alongside membership roles
// during the transition window. `organization_id = null` = a global (cross-
// tenant) assignment; tenant-scoped assignments match the active org.
const userRoleRows = await tryFind(ql, 'sys_user_role', { user_id: userId }, 200);
for (const ur of userRoleRows) {
const org = ur.organization_id ?? null;
if (org && tenantId && org !== tenantId) continue;
const r = ur.role;
if (typeof r === 'string' && r && !ctx.roles!.includes(r)) ctx.roles!.push(r);
}
// 3a. Resolve fellow-organization user IDs so RLS can scope identity
// tables (`sys_user`) to collaborators in the active org via
// `id IN (current_user.org_user_ids)`. Without this, the default
// `id = current_user.id` policy on sys_user makes @-mention pickers,
// owner/assignee lookups and reviewer selectors all return just the
// current user. Hard-capped at 1000 members per request — large
// enterprises should plug in a cache or directory adapter.
if (tenantId) {
const orgMembers = await tryFind(
ql,
'sys_member',
{ organization_id: tenantId },
1000,
);
const orgUserIds = Array.from(
new Set(
orgMembers
.map((m) => m.user_id ?? m.userId)
.filter((v): v is string => typeof v === 'string' && v.length > 0),
),
);
// Always include self even if the sys_member lookup misfires (e.g.
// API key auth where the user is recognised but not in sys_member).
if (!orgUserIds.includes(userId)) orgUserIds.push(userId);
(ctx as any).org_user_ids = orgUserIds;
} else {
// No active org → at minimum the user can see themselves.
(ctx as any).org_user_ids = [userId];
}
// Resolve user-scoped permission sets.
const upsRows = await tryFind(
ql,
'sys_user_permission_set',
tenantId
? { user_id: userId, organization_id: tenantId }
: { user_id: userId },
100,
);
const psIds = new Set<string>(
upsRows.map((r) => r.permission_set_id ?? r.permissionSetId).filter(Boolean),
);
// Resolve role-bound permission sets.
if (ctx.roles!.length > 0) {
const roleRows = await tryFind(ql, 'sys_role', { name: { $in: ctx.roles } }, 100);
const roleIds = roleRows.map((r) => r.id).filter(Boolean);
if (roleIds.length > 0) {
const rpsRows = await tryFind(
ql,
'sys_role_permission_set',
{ role_id: { $in: roleIds } },
500,
);
for (const r of rpsRows) {
const id = r.permission_set_id ?? r.permissionSetId;
if (id) psIds.add(id);
}
}
}
if (psIds.size > 0) {
// Surface permission set names through ctx.permissions so downstream
// SecurityPlugin can look them up. We store the canonical `name` field.
const psRows = await tryFind(
ql,
'sys_permission_set',
{ id: { $in: Array.from(psIds) } },
500,
);
const tabRank: Record<string, number> = {
hidden: 0,
default_off: 1,
default_on: 2,
visible: 3,
};
const mergedTabs: Record<string, 'visible' | 'hidden' | 'default_on' | 'default_off'> = {};
for (const ps of psRows) {
if (ps.name && !ctx.permissions!.includes(ps.name)) {
ctx.permissions!.push(ps.name);
}
// System permissions may be stored as JSON string in DB rows.
const sysPerms = typeof ps.system_permissions === 'string'
? safeJsonParse(ps.system_permissions, [])
: (ps.system_permissions ?? ps.systemPermissions);
if (Array.isArray(sysPerms)) {
for (const p of sysPerms) {
if (typeof p === 'string' && !ctx.systemPermissions!.includes(p)) {
ctx.systemPermissions!.push(p);
}
}
}
const tabs = typeof ps.tab_permissions === 'string'
? safeJsonParse(ps.tab_permissions, {})
: (ps.tab_permissions ?? ps.tabPermissions);
if (tabs && typeof tabs === 'object') {
for (const [app, val] of Object.entries(tabs as Record<string, unknown>)) {
if (typeof val !== 'string' || !(val in tabRank)) continue;
const cur = mergedTabs[app];
if (!cur || tabRank[val] > tabRank[cur]) {
mergedTabs[app] = val as 'visible' | 'hidden' | 'default_on' | 'default_off';
}
}
}
}
if (Object.keys(mergedTabs).length > 0) {
ctx.tabPermissions = mergedTabs;
}
}
// 4. Localization (ADR-0053 Phase 2) — reference timezone + locale resolved
// once per request from the `localization` settings and carried on the
// context. Consumers: formula today()/datetime, analytics date buckets,
// email rendering. Absent config → UTC / en-US keeps current behavior.
const localization = await resolveLocalization(opts, ql, { tenantId, userId });
ctx.timezone = localization.timezone;
ctx.locale = localization.locale;
if (localization.currency) ctx.currency = localization.currency;
return ctx;
}
/**
* Typed sentinel error thrown by SecurityPlugin (and re-thrown here) when an
* operation is denied. The dispatcher catches it and translates to HTTP 403.
*
* Kept structurally identical to {@link `@objectstack/plugin-security`}'s
* `PermissionDeniedError` so `isPermissionDeniedError` matches whichever
* class instance crosses the boundary, regardless of which package owns
* the actual class identity at runtime. We do not add a hard dependency
* on `plugin-security` here to keep the runtime usable in stack
* compositions without security enforcement.
*/
export class PermissionDeniedError extends Error {
readonly code = 'PERMISSION_DENIED';
readonly statusCode = 403;
readonly details?: Record<string, unknown>;
constructor(message: string, details?: Record<string, unknown>) {
super(message);
this.name = 'PermissionDeniedError';
this.details = details;
}
}
export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError {
if (!e || typeof e !== 'object') return false;
const anyE = e as any;
return (
anyE.name === 'PermissionDeniedError' ||
anyE.code === 'PERMISSION_DENIED' ||
(typeof anyE.message === 'string' && anyE.message.startsWith('[Security] Access denied'))
);
}