-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathresolve-execution-context.ts
More file actions
351 lines (323 loc) · 13.2 KB
/
Copy pathresolve-execution-context.ts
File metadata and controls
351 lines (323 loc) · 13.2 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
// 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;
}
/**
* Resolve the active reference timezone for an authenticated context
* (ADR-0053 Phase 2): user preference → org default → `UTC`.
*
* - User override: `sys_user_preference` row `(user_id, key='timezone')`.
* - Org default: the tenant-scoped `sys_setting` `(namespace='localization',
* key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002),
* so the row needs no tenant_id filter.
*
* Pure plumbing: nothing downstream reads `ctx.timezone` yet, so an absent
* value resolves to `UTC` and preserves today's behavior. Every read is
* defensive (via `tryFind`) and an invalid zone falls through — timezone
* resolution never blocks auth.
*/
async function resolveTimezone(ql: any, userId: string): Promise<string> {
const prefRows = await tryFind(ql, 'sys_user_preference', { user_id: userId, key: 'timezone' }, 1);
const userTz = coerceTimeZone(prefRows[0]?.value);
if (userTz) return userTz;
const settingRows = await tryFind(
ql,
'sys_setting',
{ namespace: 'localization', key: 'timezone', scope: 'tenant' },
1,
);
const orgTz = coerceTimeZone(settingRows[0]?.value);
if (orgTz) return orgTz;
return 'UTC';
}
/**
* 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;
} 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;
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);
}
}
}
// 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. Reference timezone (ADR-0053 Phase 2) — resolved once per request and
// carried on the context. No consumer reads it yet; absent config → 'UTC'
// keeps current behavior.
ctx.timezone = await resolveTimezone(ql, userId);
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'))
);
}