@chidchanun/bcp 0.2.5 → 0.2.7
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 +166 -67
- package/docs/README.md +43 -53
- package/docs/api-manifest.json +16 -3
- package/docs/api-reference.md +78 -6
- package/docs/auth-route-guards.md +58 -50
- package/docs/authorization-security.md +298 -0
- package/docs/development-logging.md +44 -1
- package/docs/docs-web-manifest.json +10 -6
- package/docs/observability.md +445 -0
- package/docs/platform-manifest.json +18 -4
- package/docs/releases/0.2.6.md +194 -0
- package/docs/releases/0.2.7.md +205 -0
- package/docs/security.md +55 -1
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/client/src/auth.ts +20 -0
- package/packages/client/src/observability.ts +22 -0
- package/packages/client/src/server.ts +16 -0
- package/packages/server/src/auth-guard.ts +111 -15
- package/packages/server/src/authorization.ts +368 -0
- package/packages/server/src/observability.ts +1258 -0
- package/packages/server/src/request-security.ts +596 -0
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
type AuthSession,
|
|
5
5
|
type AuthUser,
|
|
6
6
|
} from "./auth.js";
|
|
7
|
+
import {
|
|
8
|
+
hasPermission,
|
|
9
|
+
type AuthorizationMatch,
|
|
10
|
+
type PermissionRequirement,
|
|
11
|
+
} from "./authorization.js";
|
|
7
12
|
import {
|
|
8
13
|
redirect,
|
|
9
14
|
type RedirectStatus,
|
|
@@ -31,7 +36,15 @@ extends AuthOptions {
|
|
|
31
36
|
export interface RequireRoleOptions
|
|
32
37
|
extends RequireAuthOptions {
|
|
33
38
|
roleField?: string;
|
|
34
|
-
match?:
|
|
39
|
+
match?: AuthorizationMatch;
|
|
40
|
+
forbiddenRedirectTo?: string | URL | null;
|
|
41
|
+
forbiddenRedirectStatus?: RedirectStatus;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RequirePermissionOptions
|
|
45
|
+
extends RequireAuthOptions {
|
|
46
|
+
permissionField?: string;
|
|
47
|
+
match?: AuthorizationMatch;
|
|
35
48
|
forbiddenRedirectTo?: string | URL | null;
|
|
36
49
|
forbiddenRedirectStatus?: RedirectStatus;
|
|
37
50
|
}
|
|
@@ -186,25 +199,59 @@ export async function requireRole<
|
|
|
186
199
|
)
|
|
187
200
|
);
|
|
188
201
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
);
|
|
197
|
-
}
|
|
202
|
+
return finalizeAuthorization(
|
|
203
|
+
allowed,
|
|
204
|
+
authenticated,
|
|
205
|
+
forbiddenRedirectTo,
|
|
206
|
+
forbiddenRedirectStatus
|
|
207
|
+
);
|
|
208
|
+
}
|
|
198
209
|
|
|
199
|
-
|
|
200
|
-
|
|
210
|
+
export async function requirePermission<
|
|
211
|
+
TUser extends AuthUser = AuthUser,
|
|
212
|
+
TData extends object = Record<string, never>
|
|
213
|
+
>(
|
|
214
|
+
requiredPermission:
|
|
215
|
+
PermissionRequirement,
|
|
216
|
+
options:
|
|
217
|
+
RequirePermissionOptions = {}
|
|
218
|
+
): Promise<AuthGuardResult<TUser, TData>> {
|
|
219
|
+
const {
|
|
220
|
+
permissionField =
|
|
221
|
+
"permissions",
|
|
222
|
+
match = "any",
|
|
223
|
+
forbiddenRedirectTo = null,
|
|
224
|
+
forbiddenRedirectStatus = 303,
|
|
225
|
+
...authOptions
|
|
226
|
+
} = options;
|
|
227
|
+
const authenticated =
|
|
228
|
+
await requireAuth<TUser, TData>(
|
|
229
|
+
authOptions
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
authenticated instanceof Response
|
|
234
|
+
) {
|
|
235
|
+
return authenticated;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const allowed =
|
|
239
|
+
hasPermission(
|
|
240
|
+
authenticated.auth.user,
|
|
241
|
+
requiredPermission,
|
|
201
242
|
{
|
|
202
|
-
|
|
243
|
+
field:
|
|
244
|
+
permissionField,
|
|
245
|
+
match,
|
|
203
246
|
}
|
|
204
247
|
);
|
|
205
|
-
}
|
|
206
248
|
|
|
207
|
-
return
|
|
249
|
+
return finalizeAuthorization(
|
|
250
|
+
allowed,
|
|
251
|
+
authenticated,
|
|
252
|
+
forbiddenRedirectTo,
|
|
253
|
+
forbiddenRedirectStatus
|
|
254
|
+
);
|
|
208
255
|
}
|
|
209
256
|
|
|
210
257
|
export function createAuthGuard<
|
|
@@ -245,6 +292,22 @@ export function createRoleGuard<
|
|
|
245
292
|
);
|
|
246
293
|
}
|
|
247
294
|
|
|
295
|
+
export function createPermissionGuard<
|
|
296
|
+
TUser extends AuthUser = AuthUser,
|
|
297
|
+
TData extends object = Record<string, never>
|
|
298
|
+
>(
|
|
299
|
+
requiredPermission:
|
|
300
|
+
PermissionRequirement,
|
|
301
|
+
options:
|
|
302
|
+
RequirePermissionOptions = {}
|
|
303
|
+
): AuthGuardFunction<TUser, TData> {
|
|
304
|
+
return () =>
|
|
305
|
+
requirePermission<TUser, TData>(
|
|
306
|
+
requiredPermission,
|
|
307
|
+
options
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
248
311
|
export function getGuardAuth<
|
|
249
312
|
TUser extends AuthUser = AuthUser,
|
|
250
313
|
TData extends object = Record<string, never>
|
|
@@ -293,6 +356,39 @@ export function getGuardAuth<
|
|
|
293
356
|
AuthSession<TUser, TData>;
|
|
294
357
|
}
|
|
295
358
|
|
|
359
|
+
function finalizeAuthorization<
|
|
360
|
+
TUser extends AuthUser,
|
|
361
|
+
TData extends object
|
|
362
|
+
>(
|
|
363
|
+
allowed: boolean,
|
|
364
|
+
authenticated:
|
|
365
|
+
AuthGuardData<TUser, TData>,
|
|
366
|
+
forbiddenRedirectTo:
|
|
367
|
+
string | URL | null,
|
|
368
|
+
forbiddenRedirectStatus:
|
|
369
|
+
RedirectStatus
|
|
370
|
+
): Promise<AuthGuardResult<TUser, TData>> | AuthGuardData<TUser, TData> | Response {
|
|
371
|
+
if (allowed) {
|
|
372
|
+
return authenticated;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (
|
|
376
|
+
forbiddenRedirectTo !== null
|
|
377
|
+
) {
|
|
378
|
+
return redirect(
|
|
379
|
+
forbiddenRedirectTo,
|
|
380
|
+
forbiddenRedirectStatus
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return new Response(
|
|
385
|
+
"Forbidden",
|
|
386
|
+
{
|
|
387
|
+
status: 403,
|
|
388
|
+
}
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
296
392
|
function normalizeRequiredRoles(
|
|
297
393
|
value: RequiredRole
|
|
298
394
|
): string[] {
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
export type AuthorizationMatch =
|
|
2
|
+
| "any"
|
|
3
|
+
| "all";
|
|
4
|
+
|
|
5
|
+
export type PermissionRequirement =
|
|
6
|
+
| string
|
|
7
|
+
| readonly string[];
|
|
8
|
+
|
|
9
|
+
export interface PermissionCheckOptions {
|
|
10
|
+
field?: string;
|
|
11
|
+
match?: AuthorizationMatch;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuthorizationContext<
|
|
15
|
+
TUser extends object = Record<string, unknown>,
|
|
16
|
+
TResource = unknown,
|
|
17
|
+
TData = unknown
|
|
18
|
+
> {
|
|
19
|
+
user: TUser;
|
|
20
|
+
resource?: TResource;
|
|
21
|
+
data?: TData;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type AuthorizationPolicy<
|
|
25
|
+
TUser extends object = Record<string, unknown>,
|
|
26
|
+
TResource = unknown,
|
|
27
|
+
TData = unknown
|
|
28
|
+
> = (
|
|
29
|
+
context: AuthorizationContext<
|
|
30
|
+
TUser,
|
|
31
|
+
TResource,
|
|
32
|
+
TData
|
|
33
|
+
>
|
|
34
|
+
) => boolean | Promise<boolean>;
|
|
35
|
+
|
|
36
|
+
export class AuthorizationError
|
|
37
|
+
extends Error {
|
|
38
|
+
readonly status = 403;
|
|
39
|
+
readonly code = "FORBIDDEN";
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
message = "Forbidden"
|
|
43
|
+
) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name =
|
|
46
|
+
"AuthorizationError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getUserPermissions(
|
|
51
|
+
user: object,
|
|
52
|
+
field = "permissions"
|
|
53
|
+
): string[] {
|
|
54
|
+
const normalizedField =
|
|
55
|
+
normalizeField(
|
|
56
|
+
field
|
|
57
|
+
);
|
|
58
|
+
const value =
|
|
59
|
+
(
|
|
60
|
+
user as
|
|
61
|
+
Record<string, unknown>
|
|
62
|
+
)[normalizedField];
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
typeof value === "string"
|
|
66
|
+
) {
|
|
67
|
+
const permission =
|
|
68
|
+
value.trim();
|
|
69
|
+
|
|
70
|
+
return permission
|
|
71
|
+
? [permission]
|
|
72
|
+
: [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
!Array.isArray(
|
|
77
|
+
value
|
|
78
|
+
)
|
|
79
|
+
) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Array.from(
|
|
84
|
+
new Set(
|
|
85
|
+
value
|
|
86
|
+
.filter(
|
|
87
|
+
(
|
|
88
|
+
permission
|
|
89
|
+
): permission is string =>
|
|
90
|
+
typeof permission ===
|
|
91
|
+
"string"
|
|
92
|
+
)
|
|
93
|
+
.map(
|
|
94
|
+
(permission) =>
|
|
95
|
+
permission.trim()
|
|
96
|
+
)
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function hasPermission(
|
|
103
|
+
user: object,
|
|
104
|
+
required:
|
|
105
|
+
PermissionRequirement,
|
|
106
|
+
options:
|
|
107
|
+
PermissionCheckOptions = {}
|
|
108
|
+
): boolean {
|
|
109
|
+
const requiredPermissions =
|
|
110
|
+
normalizePermissions(
|
|
111
|
+
required
|
|
112
|
+
);
|
|
113
|
+
const assignedPermissions =
|
|
114
|
+
getUserPermissions(
|
|
115
|
+
user,
|
|
116
|
+
options.field
|
|
117
|
+
);
|
|
118
|
+
const match =
|
|
119
|
+
options.match ??
|
|
120
|
+
"any";
|
|
121
|
+
|
|
122
|
+
assertAuthorizationMatch(
|
|
123
|
+
match
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return match === "all"
|
|
127
|
+
? requiredPermissions.every(
|
|
128
|
+
(permission) =>
|
|
129
|
+
assignedPermissions.includes(
|
|
130
|
+
permission
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
: requiredPermissions.some(
|
|
134
|
+
(permission) =>
|
|
135
|
+
assignedPermissions.includes(
|
|
136
|
+
permission
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function assertPermission(
|
|
142
|
+
user: object,
|
|
143
|
+
required:
|
|
144
|
+
PermissionRequirement,
|
|
145
|
+
options:
|
|
146
|
+
PermissionCheckOptions = {}
|
|
147
|
+
): void {
|
|
148
|
+
if (
|
|
149
|
+
!hasPermission(
|
|
150
|
+
user,
|
|
151
|
+
required,
|
|
152
|
+
options
|
|
153
|
+
)
|
|
154
|
+
) {
|
|
155
|
+
throw new AuthorizationError();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function defineAuthorizationPolicy<
|
|
160
|
+
TUser extends object,
|
|
161
|
+
TResource = unknown,
|
|
162
|
+
TData = unknown
|
|
163
|
+
>(
|
|
164
|
+
policy:
|
|
165
|
+
AuthorizationPolicy<
|
|
166
|
+
TUser,
|
|
167
|
+
TResource,
|
|
168
|
+
TData
|
|
169
|
+
>
|
|
170
|
+
): AuthorizationPolicy<
|
|
171
|
+
TUser,
|
|
172
|
+
TResource,
|
|
173
|
+
TData
|
|
174
|
+
> {
|
|
175
|
+
if (
|
|
176
|
+
typeof policy !==
|
|
177
|
+
"function"
|
|
178
|
+
) {
|
|
179
|
+
throw new TypeError(
|
|
180
|
+
"BCP Authorization: policy must be a function."
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return policy;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function can<
|
|
188
|
+
TUser extends object,
|
|
189
|
+
TResource = unknown,
|
|
190
|
+
TData = unknown
|
|
191
|
+
>(
|
|
192
|
+
policy:
|
|
193
|
+
AuthorizationPolicy<
|
|
194
|
+
TUser,
|
|
195
|
+
TResource,
|
|
196
|
+
TData
|
|
197
|
+
>,
|
|
198
|
+
context:
|
|
199
|
+
AuthorizationContext<
|
|
200
|
+
TUser,
|
|
201
|
+
TResource,
|
|
202
|
+
TData
|
|
203
|
+
>
|
|
204
|
+
): Promise<boolean> {
|
|
205
|
+
assertAuthorizationContext(
|
|
206
|
+
context
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return Boolean(
|
|
210
|
+
await policy(
|
|
211
|
+
context
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function cannot<
|
|
217
|
+
TUser extends object,
|
|
218
|
+
TResource = unknown,
|
|
219
|
+
TData = unknown
|
|
220
|
+
>(
|
|
221
|
+
policy:
|
|
222
|
+
AuthorizationPolicy<
|
|
223
|
+
TUser,
|
|
224
|
+
TResource,
|
|
225
|
+
TData
|
|
226
|
+
>,
|
|
227
|
+
context:
|
|
228
|
+
AuthorizationContext<
|
|
229
|
+
TUser,
|
|
230
|
+
TResource,
|
|
231
|
+
TData
|
|
232
|
+
>
|
|
233
|
+
): Promise<boolean> {
|
|
234
|
+
return !await can(
|
|
235
|
+
policy,
|
|
236
|
+
context
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function authorize<
|
|
241
|
+
TUser extends object,
|
|
242
|
+
TResource = unknown,
|
|
243
|
+
TData = unknown
|
|
244
|
+
>(
|
|
245
|
+
policy:
|
|
246
|
+
AuthorizationPolicy<
|
|
247
|
+
TUser,
|
|
248
|
+
TResource,
|
|
249
|
+
TData
|
|
250
|
+
>,
|
|
251
|
+
context:
|
|
252
|
+
AuthorizationContext<
|
|
253
|
+
TUser,
|
|
254
|
+
TResource,
|
|
255
|
+
TData
|
|
256
|
+
>,
|
|
257
|
+
message = "Forbidden"
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
if (
|
|
260
|
+
!await can(
|
|
261
|
+
policy,
|
|
262
|
+
context
|
|
263
|
+
)
|
|
264
|
+
) {
|
|
265
|
+
throw new AuthorizationError(
|
|
266
|
+
message
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizePermissions(
|
|
272
|
+
value:
|
|
273
|
+
PermissionRequirement
|
|
274
|
+
): string[] {
|
|
275
|
+
const permissions =
|
|
276
|
+
typeof value === "string"
|
|
277
|
+
? [value]
|
|
278
|
+
: [
|
|
279
|
+
...value,
|
|
280
|
+
];
|
|
281
|
+
const normalized =
|
|
282
|
+
permissions.map(
|
|
283
|
+
(permission) =>
|
|
284
|
+
permission.trim()
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
normalized.length === 0 ||
|
|
289
|
+
normalized.some(
|
|
290
|
+
(permission) =>
|
|
291
|
+
permission.length === 0
|
|
292
|
+
)
|
|
293
|
+
) {
|
|
294
|
+
throw new TypeError(
|
|
295
|
+
"BCP Authorization: required permissions must contain non-empty strings."
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return Array.from(
|
|
300
|
+
new Set(
|
|
301
|
+
normalized
|
|
302
|
+
)
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function normalizeField(
|
|
307
|
+
value: string
|
|
308
|
+
): string {
|
|
309
|
+
const field =
|
|
310
|
+
value.trim();
|
|
311
|
+
|
|
312
|
+
if (!field) {
|
|
313
|
+
throw new TypeError(
|
|
314
|
+
"BCP Authorization: permission field must be a non-empty string."
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return field;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function assertAuthorizationMatch(
|
|
322
|
+
value: string
|
|
323
|
+
): asserts value is AuthorizationMatch {
|
|
324
|
+
if (
|
|
325
|
+
value !== "any" &&
|
|
326
|
+
value !== "all"
|
|
327
|
+
) {
|
|
328
|
+
throw new TypeError(
|
|
329
|
+
"BCP Authorization: match must be either \"any\" or \"all\"."
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function assertAuthorizationContext(
|
|
335
|
+
value: unknown
|
|
336
|
+
): asserts value is AuthorizationContext<object> {
|
|
337
|
+
if (
|
|
338
|
+
!value ||
|
|
339
|
+
typeof value !== "object" ||
|
|
340
|
+
Array.isArray(
|
|
341
|
+
value
|
|
342
|
+
) ||
|
|
343
|
+
!(
|
|
344
|
+
"user" in value
|
|
345
|
+
) ||
|
|
346
|
+
!(
|
|
347
|
+
value as {
|
|
348
|
+
user?: unknown;
|
|
349
|
+
}
|
|
350
|
+
).user ||
|
|
351
|
+
typeof (
|
|
352
|
+
value as {
|
|
353
|
+
user: unknown;
|
|
354
|
+
}
|
|
355
|
+
).user !== "object" ||
|
|
356
|
+
Array.isArray(
|
|
357
|
+
(
|
|
358
|
+
value as {
|
|
359
|
+
user: unknown;
|
|
360
|
+
}
|
|
361
|
+
).user
|
|
362
|
+
)
|
|
363
|
+
) {
|
|
364
|
+
throw new TypeError(
|
|
365
|
+
"BCP Authorization: context must contain a user object."
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|