@autorix/express 0.1.0 → 0.1.2

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 CHANGED
@@ -34,21 +34,47 @@ yarn add @autorix/express @autorix/core @autorix/storage
34
34
 
35
35
  ```typescript
36
36
  import express from 'express';
37
- import { autorixExpress, authorize } from '@autorix/express';
38
- import { Autorix } from '@autorix/core';
37
+ import { autorixExpress, authorize, autorixErrorHandler } from '@autorix/express';
38
+ import { evaluateAll } from '@autorix/core';
39
39
  import { MemoryPolicyProvider } from '@autorix/storage';
40
40
 
41
41
  const app = express();
42
+ app.use(express.json());
42
43
 
43
- // Initialize Autorix
44
+ // Initialize policy provider
44
45
  const policyProvider = new MemoryPolicyProvider();
45
- const autorix = new Autorix(policyProvider);
46
46
 
47
- // Add the Autorix middleware
47
+ // Create enforcer
48
+ const enforcer = {
49
+ can: async (input: { action: string; resource: string; context: any }) => {
50
+ // ✅ IMPORTANT: If your app allows unauthenticated requests, guard here
51
+ // to avoid storage/providers crashing when principal is null.
52
+ if (!input.context?.principal) {
53
+ return { allowed: false, reason: 'Unauthenticated' };
54
+ }
55
+
56
+ const policies = await policyProvider.getPolicies({
57
+ scope: input.context.tenantId || 'default',
58
+ principal: input.context.principal,
59
+ roleIds: input.context.principal?.roles,
60
+ });
61
+
62
+ const result = evaluateAll({
63
+ policies: policies.map(p => p.document),
64
+ action: input.action,
65
+ resource: input.resource, // ← String for matching (e.g., 'post/123')
66
+ ctx: input.context, // ← Contains resource object in ctx.resource
67
+ });
68
+
69
+ return { allowed: result.allowed, reason: result.reason };
70
+ }
71
+ };
72
+
73
+ // Add the Autorix middleware (must be registered before routes)
48
74
  app.use(autorixExpress({
49
- enforcer: autorix,
75
+ enforcer,
50
76
  getPrincipal: async (req) => {
51
- // Extract user from your auth middleware
77
+ // Extract user from your auth middleware (e.g., JWT/Passport/session)
52
78
  return req.user ? { id: req.user.id, roles: req.user.roles } : null;
53
79
  },
54
80
  getTenant: async (req) => {
@@ -58,20 +84,23 @@ app.use(autorixExpress({
58
84
  }));
59
85
 
60
86
  // Protect routes with authorize middleware
61
- app.get('/admin/users',
62
- authorize('user:list'),
87
+ app.get(
88
+ '/admin/users',
89
+ authorize('user:list', { requireAuth: true }),
63
90
  (req, res) => {
64
91
  res.json({ message: 'Authorized!' });
65
92
  }
66
93
  );
67
94
 
68
- app.delete('/posts/:id',
95
+ app.delete(
96
+ '/posts/:id',
69
97
  authorize({
70
98
  action: 'post:delete',
99
+ requireAuth: true,
71
100
  resource: {
72
101
  type: 'post',
73
102
  idFrom: (req) => req.params.id,
74
- loader: async (id) => await db.posts.findById(id)
103
+ loader: async (id) => await db.posts.findById(id),
75
104
  }
76
105
  }),
77
106
  (req, res) => {
@@ -79,6 +108,10 @@ app.delete('/posts/:id',
79
108
  }
80
109
  );
81
110
 
111
+ // ✅ IMPORTANT: Register Autorix error handler at the end
112
+ // so authorization errors return clean HTTP responses (401/403) instead of stack traces.
113
+ app.use(autorixErrorHandler());
114
+
82
115
  app.listen(3000);
83
116
  ```
84
117
 
@@ -176,6 +209,49 @@ authorize({
176
209
  })
177
210
  ```
178
211
 
212
+ ## 🔑 Understanding Resources
213
+
214
+ Autorix uses **two separate concepts** for resources:
215
+
216
+ 1. **Resource String** (for pattern matching in policies)
217
+ - Format: `type/id` or `type/*`
218
+ - Used in policy `Resource` field
219
+ - Example: `'post/123'`, `'document/*'`
220
+
221
+ 2. **Resource Object** (for attribute-based conditions)
222
+ - Contains resource properties/attributes
223
+ - Used in policy `Condition` blocks
224
+ - Example: `{ type: 'post', id: '123', authorId: 'u1' }`
225
+
226
+ **How they work together:**
227
+
228
+ ```typescript
229
+ // Policy matches by string pattern
230
+ Statement: [{
231
+ Effect: 'Allow',
232
+ Action: ['post:delete'],
233
+ Resource: 'post/*', // ← Matches 'post/123'
234
+ Condition: {
235
+ StringEquals: {
236
+ 'resource.authorId': '${principal.id}' // ← Uses resource object
237
+ }
238
+ }
239
+ }]
240
+
241
+ // Middleware automatically handles both
242
+ authorize({
243
+ action: 'post:delete',
244
+ resource: {
245
+ type: 'post',
246
+ idFrom: (req) => req.params.id, // → Builds string: 'post/123'
247
+ loader: async (id) => {
248
+ const post = await db.posts.findById(id);
249
+ return { authorId: post.authorId }; // → Object for conditions
250
+ }
251
+ }
252
+ })
253
+ ```
254
+
179
255
  ## 🎯 Usage Examples
180
256
 
181
257
  ### Role-Based Access Control (RBAC)
@@ -220,16 +296,16 @@ app.get('/admin/settings',
220
296
  ### Attribute-Based Access Control (ABAC)
221
297
 
222
298
  ```typescript
223
- // Policy with conditions
299
+ // Policy with conditions - checks resource attributes
224
300
  await provider.setPolicy('owner-only', {
225
301
  Version: '2024-01-01',
226
302
  Statement: [{
227
303
  Effect: 'Allow',
228
304
  Action: ['post:update', 'post:delete'],
229
- Resource: ['post/*'],
305
+ Resource: ['post/*'], // ← Matches 'post/123' string pattern
230
306
  Condition: {
231
307
  StringEquals: {
232
- 'principal.id': '${resource.ownerId}'
308
+ 'resource.authorId': '${principal.id}' // ← Checks resource object property
233
309
  }
234
310
  }
235
311
  }]
@@ -244,12 +320,16 @@ app.put('/posts/:id',
244
320
  idFrom: (req) => req.params.id,
245
321
  loader: async (id) => {
246
322
  const post = await db.posts.findById(id);
247
- return { ...post, ownerId: post.userId };
323
+ // Return object with properties for conditions
324
+ return {
325
+ authorId: post.authorId, // ← This becomes resource.authorId in conditions
326
+ status: post.status
327
+ };
248
328
  }
249
329
  }
250
330
  }),
251
331
  (req, res) => {
252
- // Only post owner can update
332
+ // Only post author can update
253
333
  res.json({ message: 'Post updated!' });
254
334
  }
255
335
  );
@@ -359,31 +439,71 @@ app.use(autorixExpress({
359
439
  }));
360
440
  ```
361
441
 
442
+ ---
443
+
362
444
  ## 🔧 Error Handling
363
445
 
364
- The package provides custom error classes:
446
+ `@autorix/express` provides an official error handler middleware to ensure authorization errors
447
+ are returned as clean HTTP responses (`401`, `403`) instead of unhandled stack traces.
365
448
 
366
- ```typescript
367
- import {
368
- AutorixForbiddenError,
369
- AutorixUnauthenticatedError,
370
- AutorixMissingMiddlewareError
371
- } from '@autorix/express';
449
+ ### ✅ Recommended (default)
450
+
451
+ ```ts
452
+ import { autorixErrorHandler } from '@autorix/express';
453
+
454
+ // Register at the end (after routes)
455
+ app.use(autorixErrorHandler());
456
+ ```
457
+
458
+ This middleware automatically handles all `AutorixHttpError` instances and formats them as JSON responses.
459
+
460
+ **Always register this middleware after all routes.**
461
+
462
+ ---
463
+
464
+ ### 🧩 Custom error handling (optional)
465
+
466
+ If you want full control over the error response format or logging, you can implement
467
+ your own handler using the exported error classes.
468
+
469
+ ```ts
470
+ import { AutorixHttpError } from '@autorix/express';
372
471
 
373
472
  app.use((err, req, res, next) => {
374
- if (err instanceof AutorixForbiddenError) {
375
- return res.status(403).json({ error: 'Forbidden' });
376
- }
377
- if (err instanceof AutorixUnauthenticatedError) {
378
- return res.status(401).json({ error: 'Authentication required' });
379
- }
380
- if (err instanceof AutorixMissingMiddlewareError) {
381
- return res.status(500).json({ error: 'Autorix middleware not configured' });
473
+ if (err instanceof AutorixHttpError) {
474
+ return res.status(err.statusCode).json({
475
+ error: {
476
+ code: err.code,
477
+ message: err.message
478
+ }
479
+ });
382
480
  }
481
+
383
482
  next(err);
384
483
  });
385
484
  ```
386
485
 
486
+ ---
487
+
488
+ ### ⚠️ Important notes
489
+
490
+ * If no error handler is registered, Express will print authorization errors as stack traces.
491
+ * Using `autorixErrorHandler()` is strongly recommended to avoid this behavior.
492
+ * For protected routes, use `requireAuth: true` in `authorize()` to return a clean `401 Unauthenticated`
493
+ response when no principal is present.
494
+
495
+ ---
496
+
497
+ ## 🧠 Why is this required?
498
+
499
+ Express does not support automatic global error handling inside normal middleware.
500
+ For this reason, error handlers must be registered explicitly at the application level.
501
+
502
+ This design follows the same pattern used by mature Express libraries
503
+ (e.g. `passport`, `express-rate-limit`, `celebrate`).
504
+
505
+ ---
506
+
387
507
  ## 🔗 Related Packages
388
508
 
389
509
  - [@autorix/core](../core) - Core policy evaluation engine
package/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  AutorixMissingMiddlewareError: () => AutorixMissingMiddlewareError,
27
27
  AutorixUnauthenticatedError: () => AutorixUnauthenticatedError,
28
28
  authorize: () => authorize,
29
+ autorixErrorHandler: () => autorixErrorHandler,
29
30
  autorixExpress: () => autorixExpress,
30
31
  buildRequestContext: () => buildRequestContext,
31
32
  resolvePrincipal: () => resolvePrincipal,
@@ -124,17 +125,31 @@ function autorixExpress(opts) {
124
125
  const context = await buildRequestContext(req, opts);
125
126
  req.autorix = {
126
127
  context,
127
- can: /* @__PURE__ */ __name(async (action, resource, ctxExtra) => {
128
+ can: /* @__PURE__ */ __name(async (action, resourceObj, ctxExtra) => {
129
+ let resourceString = "*";
130
+ if (typeof resourceObj === "object" && resourceObj && "type" in resourceObj) {
131
+ const resType = resourceObj.type;
132
+ const resId = resourceObj.id;
133
+ resourceString = resId ? `${resType}/${resId}` : `${resType}/*`;
134
+ } else if (typeof resourceObj === "string") {
135
+ resourceString = resourceObj;
136
+ } else if (resourceObj === void 0) {
137
+ const parts = action.split(":");
138
+ if (parts.length > 1) {
139
+ resourceString = `${parts[0]}/*`;
140
+ }
141
+ }
128
142
  const decision = await opts.enforcer.can({
129
143
  action,
144
+ resource: resourceString,
130
145
  context: {
131
146
  ...context,
147
+ resource: resourceObj,
132
148
  attributes: {
133
149
  ...context.attributes ?? {},
134
150
  ...ctxExtra ?? {}
135
151
  }
136
- },
137
- resource
152
+ }
138
153
  });
139
154
  opts.onDecision?.({
140
155
  ...decision,
@@ -160,9 +175,16 @@ async function resolveResource(spec, req) {
160
175
  if (typeof spec === "string") return {
161
176
  type: spec
162
177
  };
163
- if ("loader" in spec) {
178
+ if ("loader" in spec && typeof spec.loader === "function" && typeof spec.idFrom === "function") {
164
179
  const id = spec.idFrom(req);
165
180
  const data = await spec.loader(id, req);
181
+ if (data && typeof data === "object" && !Array.isArray(data)) {
182
+ return {
183
+ type: spec.type,
184
+ id,
185
+ ...data
186
+ };
187
+ }
166
188
  return {
167
189
  type: spec.type,
168
190
  id,
@@ -199,6 +221,42 @@ function authorize(actionOrConfig, cfg) {
199
221
  }, "authorizeMiddleware");
200
222
  }
201
223
  __name(authorize, "authorize");
224
+
225
+ // src/middleware/autorixErrorHandler.ts
226
+ function autorixErrorHandler(options = {}) {
227
+ const exposeStack = options.exposeStack ?? false;
228
+ const format = options.format ?? "json";
229
+ return (err, _req, res, next) => {
230
+ if (res.headersSent) return next(err);
231
+ if (err instanceof AutorixHttpError) {
232
+ if (format === "problem+json") {
233
+ return res.status(err.statusCode).type("application/problem+json").json({
234
+ type: `https://autorix.dev/errors/${err.code}`,
235
+ title: err.code,
236
+ status: err.statusCode,
237
+ detail: err.message
238
+ });
239
+ }
240
+ return res.status(err.statusCode).json({
241
+ error: {
242
+ code: err.code,
243
+ message: err.message
244
+ }
245
+ });
246
+ }
247
+ const status = err?.statusCode ?? err?.status ?? 500;
248
+ return res.status(status).json({
249
+ error: {
250
+ code: "INTERNAL_ERROR",
251
+ message: status === 500 ? "Internal Error" : err?.message ?? "Error",
252
+ ...exposeStack ? {
253
+ stack: String(err?.stack ?? "")
254
+ } : {}
255
+ }
256
+ });
257
+ };
258
+ }
259
+ __name(autorixErrorHandler, "autorixErrorHandler");
202
260
  // Annotate the CommonJS export names for ESM import in node:
203
261
  0 && (module.exports = {
204
262
  AutorixForbiddenError,
@@ -206,6 +264,7 @@ __name(authorize, "authorize");
206
264
  AutorixMissingMiddlewareError,
207
265
  AutorixUnauthenticatedError,
208
266
  authorize,
267
+ autorixErrorHandler,
209
268
  autorixExpress,
210
269
  buildRequestContext,
211
270
  resolvePrincipal,
package/dist/index.d.cts CHANGED
@@ -17,19 +17,19 @@ type AutorixRequestContext = {
17
17
  type ResourceSpec = string | {
18
18
  type: string;
19
19
  id?: string;
20
- data?: unknown;
20
+ [key: string]: unknown;
21
21
  } | {
22
22
  type: string;
23
23
  idFrom: (req: Request) => string;
24
- loader: (id: string, req: Request) => Promise<unknown>;
24
+ loader: (id: string, req: Request) => Promise<Record<string, unknown>>;
25
25
  };
26
26
  type GetContextFn = (req: Request) => Partial<AutorixRequestContext> | Promise<Partial<AutorixRequestContext>>;
27
27
  type AutorixExpressOptions = {
28
28
  enforcer: {
29
29
  can: (input: {
30
30
  action: string;
31
+ resource: string;
31
32
  context: AutorixRequestContext;
32
- resource?: unknown;
33
33
  }) => Promise<{
34
34
  allowed: boolean;
35
35
  reason?: string;
@@ -95,4 +95,10 @@ declare class AutorixMissingMiddlewareError extends AutorixHttpError {
95
95
  constructor(details?: unknown);
96
96
  }
97
97
 
98
- export { type AutorixErrorCode, type AutorixExpressOptions, AutorixForbiddenError, AutorixHttpError, AutorixMissingMiddlewareError, type AutorixRequestContext, AutorixUnauthenticatedError, type GetContextFn, type GetPrincipalFn, type Principal, type ResourceSpec, authorize, autorixExpress, buildRequestContext, resolvePrincipal, resolveResource };
98
+ type AutorixErrorHandlerOptions = {
99
+ exposeStack?: boolean;
100
+ format?: "json" | "problem+json";
101
+ };
102
+ declare function autorixErrorHandler(options?: AutorixErrorHandlerOptions): (err: any, _req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
103
+
104
+ export { type AutorixErrorCode, type AutorixErrorHandlerOptions, type AutorixExpressOptions, AutorixForbiddenError, AutorixHttpError, AutorixMissingMiddlewareError, type AutorixRequestContext, AutorixUnauthenticatedError, type GetContextFn, type GetPrincipalFn, type Principal, type ResourceSpec, authorize, autorixErrorHandler, autorixExpress, buildRequestContext, resolvePrincipal, resolveResource };
package/dist/index.d.ts CHANGED
@@ -17,19 +17,19 @@ type AutorixRequestContext = {
17
17
  type ResourceSpec = string | {
18
18
  type: string;
19
19
  id?: string;
20
- data?: unknown;
20
+ [key: string]: unknown;
21
21
  } | {
22
22
  type: string;
23
23
  idFrom: (req: Request) => string;
24
- loader: (id: string, req: Request) => Promise<unknown>;
24
+ loader: (id: string, req: Request) => Promise<Record<string, unknown>>;
25
25
  };
26
26
  type GetContextFn = (req: Request) => Partial<AutorixRequestContext> | Promise<Partial<AutorixRequestContext>>;
27
27
  type AutorixExpressOptions = {
28
28
  enforcer: {
29
29
  can: (input: {
30
30
  action: string;
31
+ resource: string;
31
32
  context: AutorixRequestContext;
32
- resource?: unknown;
33
33
  }) => Promise<{
34
34
  allowed: boolean;
35
35
  reason?: string;
@@ -95,4 +95,10 @@ declare class AutorixMissingMiddlewareError extends AutorixHttpError {
95
95
  constructor(details?: unknown);
96
96
  }
97
97
 
98
- export { type AutorixErrorCode, type AutorixExpressOptions, AutorixForbiddenError, AutorixHttpError, AutorixMissingMiddlewareError, type AutorixRequestContext, AutorixUnauthenticatedError, type GetContextFn, type GetPrincipalFn, type Principal, type ResourceSpec, authorize, autorixExpress, buildRequestContext, resolvePrincipal, resolveResource };
98
+ type AutorixErrorHandlerOptions = {
99
+ exposeStack?: boolean;
100
+ format?: "json" | "problem+json";
101
+ };
102
+ declare function autorixErrorHandler(options?: AutorixErrorHandlerOptions): (err: any, _req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
103
+
104
+ export { type AutorixErrorCode, type AutorixErrorHandlerOptions, type AutorixExpressOptions, AutorixForbiddenError, AutorixHttpError, AutorixMissingMiddlewareError, type AutorixRequestContext, AutorixUnauthenticatedError, type GetContextFn, type GetPrincipalFn, type Principal, type ResourceSpec, authorize, autorixErrorHandler, autorixExpress, buildRequestContext, resolvePrincipal, resolveResource };
package/dist/index.js CHANGED
@@ -92,17 +92,31 @@ function autorixExpress(opts) {
92
92
  const context = await buildRequestContext(req, opts);
93
93
  req.autorix = {
94
94
  context,
95
- can: /* @__PURE__ */ __name(async (action, resource, ctxExtra) => {
95
+ can: /* @__PURE__ */ __name(async (action, resourceObj, ctxExtra) => {
96
+ let resourceString = "*";
97
+ if (typeof resourceObj === "object" && resourceObj && "type" in resourceObj) {
98
+ const resType = resourceObj.type;
99
+ const resId = resourceObj.id;
100
+ resourceString = resId ? `${resType}/${resId}` : `${resType}/*`;
101
+ } else if (typeof resourceObj === "string") {
102
+ resourceString = resourceObj;
103
+ } else if (resourceObj === void 0) {
104
+ const parts = action.split(":");
105
+ if (parts.length > 1) {
106
+ resourceString = `${parts[0]}/*`;
107
+ }
108
+ }
96
109
  const decision = await opts.enforcer.can({
97
110
  action,
111
+ resource: resourceString,
98
112
  context: {
99
113
  ...context,
114
+ resource: resourceObj,
100
115
  attributes: {
101
116
  ...context.attributes ?? {},
102
117
  ...ctxExtra ?? {}
103
118
  }
104
- },
105
- resource
119
+ }
106
120
  });
107
121
  opts.onDecision?.({
108
122
  ...decision,
@@ -128,9 +142,16 @@ async function resolveResource(spec, req) {
128
142
  if (typeof spec === "string") return {
129
143
  type: spec
130
144
  };
131
- if ("loader" in spec) {
145
+ if ("loader" in spec && typeof spec.loader === "function" && typeof spec.idFrom === "function") {
132
146
  const id = spec.idFrom(req);
133
147
  const data = await spec.loader(id, req);
148
+ if (data && typeof data === "object" && !Array.isArray(data)) {
149
+ return {
150
+ type: spec.type,
151
+ id,
152
+ ...data
153
+ };
154
+ }
134
155
  return {
135
156
  type: spec.type,
136
157
  id,
@@ -167,12 +188,49 @@ function authorize(actionOrConfig, cfg) {
167
188
  }, "authorizeMiddleware");
168
189
  }
169
190
  __name(authorize, "authorize");
191
+
192
+ // src/middleware/autorixErrorHandler.ts
193
+ function autorixErrorHandler(options = {}) {
194
+ const exposeStack = options.exposeStack ?? false;
195
+ const format = options.format ?? "json";
196
+ return (err, _req, res, next) => {
197
+ if (res.headersSent) return next(err);
198
+ if (err instanceof AutorixHttpError) {
199
+ if (format === "problem+json") {
200
+ return res.status(err.statusCode).type("application/problem+json").json({
201
+ type: `https://autorix.dev/errors/${err.code}`,
202
+ title: err.code,
203
+ status: err.statusCode,
204
+ detail: err.message
205
+ });
206
+ }
207
+ return res.status(err.statusCode).json({
208
+ error: {
209
+ code: err.code,
210
+ message: err.message
211
+ }
212
+ });
213
+ }
214
+ const status = err?.statusCode ?? err?.status ?? 500;
215
+ return res.status(status).json({
216
+ error: {
217
+ code: "INTERNAL_ERROR",
218
+ message: status === 500 ? "Internal Error" : err?.message ?? "Error",
219
+ ...exposeStack ? {
220
+ stack: String(err?.stack ?? "")
221
+ } : {}
222
+ }
223
+ });
224
+ };
225
+ }
226
+ __name(autorixErrorHandler, "autorixErrorHandler");
170
227
  export {
171
228
  AutorixForbiddenError,
172
229
  AutorixHttpError,
173
230
  AutorixMissingMiddlewareError,
174
231
  AutorixUnauthenticatedError,
175
232
  authorize,
233
+ autorixErrorHandler,
176
234
  autorixExpress,
177
235
  buildRequestContext,
178
236
  resolvePrincipal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autorix/express",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Express.js integration for Autorix policy-based authorization (RBAC + ABAC)",
5
5
  "keywords": [
6
6
  "authorization",
@@ -17,7 +17,7 @@
17
17
  "repository": {
18
18
  "type": "git",
19
19
  "url": "https://github.com/chechooxd/autorix.git",
20
- "directory": "packages/autorix-express"
20
+ "directory": "packages/express"
21
21
  },
22
22
  "bugs": {
23
23
  "url": "https://github.com/chechooxd/autorix/issues"
@@ -55,7 +55,8 @@
55
55
  "@types/express": "^5.0.6",
56
56
  "@types/supertest": "^6.0.3",
57
57
  "express": "^5.2.1",
58
- "supertest": "^7.2.2"
58
+ "supertest": "^7.2.2",
59
+ "@autorix/storage": "^0.1.0"
59
60
  },
60
61
  "engines": {
61
62
  "node": ">=18"