odgn-rights 0.1.0 → 0.5.0
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 +489 -0
- package/dist/adapters/base-adapter.d.ts +83 -0
- package/dist/adapters/base-adapter.js +142 -0
- package/dist/adapters/factories.d.ts +31 -0
- package/dist/adapters/factories.js +48 -0
- package/dist/adapters/index.d.ts +11 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/postgres-adapter.d.ts +51 -0
- package/dist/adapters/postgres-adapter.js +469 -0
- package/dist/adapters/redis-adapter.d.ts +84 -0
- package/dist/adapters/redis-adapter.js +673 -0
- package/dist/adapters/schema.d.ts +25 -0
- package/dist/adapters/schema.js +186 -0
- package/dist/adapters/sqlite-adapter.d.ts +78 -0
- package/dist/adapters/sqlite-adapter.js +655 -0
- package/dist/adapters/types.d.ts +174 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/commands/check.d.ts +2 -0
- package/dist/cli/commands/check.js +38 -0
- package/dist/cli/commands/explain.d.ts +2 -0
- package/dist/cli/commands/explain.js +93 -0
- package/dist/cli/commands/validate.d.ts +2 -0
- package/dist/cli/commands/validate.js +177 -0
- package/dist/cli/helpers/config-loader.d.ts +3 -0
- package/dist/cli/helpers/config-loader.js +13 -0
- package/dist/cli/helpers/flag-parser.d.ts +3 -0
- package/dist/cli/helpers/flag-parser.js +40 -0
- package/dist/cli/helpers/output.d.ts +10 -0
- package/dist/cli/helpers/output.js +29 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +15 -0
- package/dist/cli/types.d.ts +10 -0
- package/dist/cli/types.js +1 -0
- package/dist/helpers.d.ts +16 -0
- package/dist/{utils.js → helpers.js} +22 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/integrations/elysia.d.ts +235 -0
- package/dist/integrations/elysia.js +375 -0
- package/dist/right.d.ts +25 -1
- package/dist/right.js +183 -19
- package/dist/rights.d.ts +31 -0
- package/dist/rights.js +162 -31
- package/dist/role-registry.d.ts +9 -0
- package/dist/role-registry.js +15 -0
- package/dist/role.d.ts +3 -1
- package/dist/role.js +11 -0
- package/dist/subject-registry.d.ts +77 -0
- package/dist/subject-registry.js +123 -0
- package/dist/subject.d.ts +21 -2
- package/dist/subject.js +51 -8
- package/package.json +63 -7
- package/dist/utils.d.ts +0 -2
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import { Flags, hasBit } from './constants';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a path string, detecting and stripping negation prefix.
|
|
4
|
+
* Handles double negation (!! becomes positive).
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* parsePath('!/api/internal') // { path: '/api/internal', negated: true }
|
|
8
|
+
* parsePath('/api/public') // { path: '/api/public', negated: false }
|
|
9
|
+
* parsePath('!!/api/path') // { path: '/api/path', negated: false }
|
|
10
|
+
*/
|
|
11
|
+
export const parsePath = (p) => {
|
|
12
|
+
let trimmed = p.trim();
|
|
13
|
+
let negated = false;
|
|
14
|
+
// Handle negation prefix(es) - !! cancels out
|
|
15
|
+
while (trimmed.startsWith('!')) {
|
|
16
|
+
negated = !negated;
|
|
17
|
+
trimmed = trimmed.slice(1).trimStart();
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
negated,
|
|
21
|
+
path: normalizePath(trimmed)
|
|
22
|
+
};
|
|
23
|
+
};
|
|
2
24
|
export const normalizePath = (p) => {
|
|
3
25
|
if (!p) {
|
|
4
26
|
return '/';
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ElysiaJS integration for odgn-rights
|
|
3
|
+
*
|
|
4
|
+
* @module odgn-rights/integrations/elysia
|
|
5
|
+
*
|
|
6
|
+
* This module provides middleware and utilities for integrating
|
|
7
|
+
* odgn-rights with ElysiaJS applications.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { Elysia } from 'elysia';
|
|
12
|
+
* import { elysiaRights } from 'odgn-rights/integrations/elysia';
|
|
13
|
+
*
|
|
14
|
+
* const app = new Elysia()
|
|
15
|
+
* .use(elysiaRights({
|
|
16
|
+
* registry: subjectRegistry,
|
|
17
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
18
|
+
* }))
|
|
19
|
+
* .get('/users', () => 'list users')
|
|
20
|
+
* .listen(3000);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import type { Flags } from '../constants';
|
|
24
|
+
import type { ConditionContext } from '../right';
|
|
25
|
+
import type { Rights } from '../rights';
|
|
26
|
+
import type { Subject } from '../subject';
|
|
27
|
+
import type { SubjectRegistry } from '../subject-registry';
|
|
28
|
+
/**
|
|
29
|
+
* Configuration options for the Elysia rights middleware
|
|
30
|
+
*/
|
|
31
|
+
export type ElysiaRightsOptions<Store = unknown, Derive = unknown> = {
|
|
32
|
+
/**
|
|
33
|
+
* Function to determine the required flags based on the request.
|
|
34
|
+
* Defaults to mapping HTTP methods to flags:
|
|
35
|
+
* - GET, HEAD, OPTIONS -> READ
|
|
36
|
+
* - POST -> CREATE
|
|
37
|
+
* - PUT, PATCH -> WRITE
|
|
38
|
+
* - DELETE -> DELETE
|
|
39
|
+
*/
|
|
40
|
+
flagMapper?: (ctx: {
|
|
41
|
+
method: string;
|
|
42
|
+
path: string;
|
|
43
|
+
request: Request;
|
|
44
|
+
}) => Flags;
|
|
45
|
+
/**
|
|
46
|
+
* Function to build the condition context for ABAC-style checks.
|
|
47
|
+
* The returned context is passed to the permission check.
|
|
48
|
+
*/
|
|
49
|
+
getContext?: (ctx: {
|
|
50
|
+
headers: Headers;
|
|
51
|
+
path: string;
|
|
52
|
+
request: Request;
|
|
53
|
+
store: Store;
|
|
54
|
+
subject: Subject;
|
|
55
|
+
} & Derive) => ConditionContext | Promise<ConditionContext>;
|
|
56
|
+
/**
|
|
57
|
+
* Function to extract the subject or subject identifier from the request context.
|
|
58
|
+
* If registry is provided, this should return a string identifier.
|
|
59
|
+
* Otherwise, it should return a Subject instance directly.
|
|
60
|
+
*/
|
|
61
|
+
getSubject: (ctx: {
|
|
62
|
+
headers: Headers;
|
|
63
|
+
path: string;
|
|
64
|
+
request: Request;
|
|
65
|
+
store: Store;
|
|
66
|
+
} & Derive) => string | Subject | undefined | null | Promise<string | Subject | undefined | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Custom handler when no subject is found.
|
|
69
|
+
* Defaults to returning 401 status.
|
|
70
|
+
*/
|
|
71
|
+
onNoSubject?: (ctx: {
|
|
72
|
+
path: string;
|
|
73
|
+
request: Request;
|
|
74
|
+
}) => Response | Promise<Response>;
|
|
75
|
+
/**
|
|
76
|
+
* Custom handler for unauthorized responses.
|
|
77
|
+
* Defaults to returning 403 status.
|
|
78
|
+
*/
|
|
79
|
+
onUnauthorized?: (ctx: {
|
|
80
|
+
path: string;
|
|
81
|
+
request: Request;
|
|
82
|
+
requiredFlags: Flags;
|
|
83
|
+
subject?: Subject;
|
|
84
|
+
}) => Response | Promise<Response>;
|
|
85
|
+
/**
|
|
86
|
+
* Function to map the request path to a rights path.
|
|
87
|
+
* Useful for stripping prefixes or transforming paths.
|
|
88
|
+
* Defaults to using the request path as-is.
|
|
89
|
+
*/
|
|
90
|
+
pathMapper?: (ctx: {
|
|
91
|
+
method: string;
|
|
92
|
+
path: string;
|
|
93
|
+
request: Request;
|
|
94
|
+
}) => string;
|
|
95
|
+
/**
|
|
96
|
+
* The SubjectRegistry to look up subjects from.
|
|
97
|
+
* If provided, getSubject should return the subject identifier.
|
|
98
|
+
*/
|
|
99
|
+
registry?: SubjectRegistry;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Options for standalone rights-based checks (without subjects)
|
|
103
|
+
*/
|
|
104
|
+
export type ElysiaRightsStandaloneOptions<Store = unknown, Derive = unknown> = Omit<ElysiaRightsOptions<Store, Derive>, 'registry' | 'getSubject'> & {
|
|
105
|
+
/**
|
|
106
|
+
* A Rights instance to check against directly.
|
|
107
|
+
* Use this when you don't need subject/role-based access control.
|
|
108
|
+
*/
|
|
109
|
+
rights: Rights;
|
|
110
|
+
};
|
|
111
|
+
type ElysiaContext = {
|
|
112
|
+
[key: string]: unknown;
|
|
113
|
+
path: string;
|
|
114
|
+
request: Request;
|
|
115
|
+
set: {
|
|
116
|
+
status?: number | string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Create an Elysia plugin for rights-based authorization.
|
|
121
|
+
*
|
|
122
|
+
* @example Using with SubjectRegistry
|
|
123
|
+
* ```typescript
|
|
124
|
+
* import { Elysia } from 'elysia';
|
|
125
|
+
* import { elysiaRights } from 'odgn-rights/integrations/elysia';
|
|
126
|
+
*
|
|
127
|
+
* const app = new Elysia()
|
|
128
|
+
* .use(elysiaRights({
|
|
129
|
+
* registry: subjectRegistry,
|
|
130
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
131
|
+
* }))
|
|
132
|
+
* .get('/users', () => 'list users')
|
|
133
|
+
* .listen(3000);
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* @example Using with direct Subject
|
|
137
|
+
* ```typescript
|
|
138
|
+
* const app = new Elysia()
|
|
139
|
+
* .derive(({ headers }) => ({
|
|
140
|
+
* user: getUserFromToken(headers.get('authorization'))
|
|
141
|
+
* }))
|
|
142
|
+
* .use(elysiaRights({
|
|
143
|
+
* getSubject: ({ user }) => user?.subject
|
|
144
|
+
* }))
|
|
145
|
+
* .get('/users', () => 'list users')
|
|
146
|
+
* .listen(3000);
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export declare const elysiaRights: <Store = unknown, Derive = unknown>(options: ElysiaRightsOptions<Store, Derive>) => any;
|
|
150
|
+
/**
|
|
151
|
+
* Create an Elysia plugin for rights-based authorization using a Rights instance directly.
|
|
152
|
+
* Use this when you don't need subject/role-based access control.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* import { Elysia } from 'elysia';
|
|
157
|
+
* import { elysiaRightsStandalone } from 'odgn-rights/integrations/elysia';
|
|
158
|
+
* import { Rights, Flags } from 'odgn-rights';
|
|
159
|
+
*
|
|
160
|
+
* const rights = new Rights();
|
|
161
|
+
* rights.allow('/api/**', Flags.READ);
|
|
162
|
+
* rights.allow('/api/admin/**', Flags.ALL);
|
|
163
|
+
*
|
|
164
|
+
* const app = new Elysia()
|
|
165
|
+
* .use(elysiaRightsStandalone({ rights }))
|
|
166
|
+
* .get('/api/users', () => 'list users')
|
|
167
|
+
* .listen(3000);
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export declare const elysiaRightsStandalone: <Store = unknown, Derive = unknown>(options: ElysiaRightsStandaloneOptions<Store, Derive>) => any;
|
|
171
|
+
/**
|
|
172
|
+
* Type for beforeHandle guard configuration
|
|
173
|
+
*/
|
|
174
|
+
export type RightsGuardConfig = {
|
|
175
|
+
beforeHandle: (ctx: ElysiaContext) => Promise<Response | object | void>;
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Create a guard configuration for use with Elysia's .guard() method.
|
|
179
|
+
* This allows more fine-grained control over which routes are protected.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```typescript
|
|
183
|
+
* import { Elysia } from 'elysia';
|
|
184
|
+
* import { createRightsGuard } from 'odgn-rights/integrations/elysia';
|
|
185
|
+
*
|
|
186
|
+
* const guard = createRightsGuard({
|
|
187
|
+
* registry: subjectRegistry,
|
|
188
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
189
|
+
* });
|
|
190
|
+
*
|
|
191
|
+
* const app = new Elysia()
|
|
192
|
+
* .get('/public', () => 'anyone can see this')
|
|
193
|
+
* .guard(guard, (app) =>
|
|
194
|
+
* app
|
|
195
|
+
* .get('/protected', () => 'only authorized users')
|
|
196
|
+
* .post('/protected', () => 'create something')
|
|
197
|
+
* )
|
|
198
|
+
* .listen(3000);
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export declare const createRightsGuard: <Store = unknown, Derive = unknown>(options: ElysiaRightsOptions<Store, Derive>) => {
|
|
202
|
+
beforeHandle({ path, request, set, ...rest }: ElysiaContext): Promise<Response | {
|
|
203
|
+
error: string;
|
|
204
|
+
message: string;
|
|
205
|
+
} | undefined>;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Create a macro for declarative per-route authorization.
|
|
209
|
+
* This allows you to specify rights requirements directly on route definitions.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```typescript
|
|
213
|
+
* import { Elysia } from 'elysia';
|
|
214
|
+
* import { createRightsMacro } from 'odgn-rights/integrations/elysia';
|
|
215
|
+
* import { Flags } from 'odgn-rights';
|
|
216
|
+
*
|
|
217
|
+
* const rightsMacro = createRightsMacro({
|
|
218
|
+
* registry: subjectRegistry,
|
|
219
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
220
|
+
* });
|
|
221
|
+
*
|
|
222
|
+
* const app = new Elysia()
|
|
223
|
+
* .use(rightsMacro)
|
|
224
|
+
* .get('/public', () => 'anyone')
|
|
225
|
+
* .get('/users', () => 'list users', {
|
|
226
|
+
* rights: { path: '/users', flags: Flags.READ }
|
|
227
|
+
* })
|
|
228
|
+
* .delete('/users/:id', () => 'delete user', {
|
|
229
|
+
* rights: { path: '/users/*', flags: Flags.DELETE }
|
|
230
|
+
* })
|
|
231
|
+
* .listen(3000);
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
export declare const createRightsMacro: <Store = unknown, Derive = unknown>(options: ElysiaRightsOptions<Store, Derive>) => any;
|
|
235
|
+
export {};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ElysiaJS integration for odgn-rights
|
|
3
|
+
*
|
|
4
|
+
* @module odgn-rights/integrations/elysia
|
|
5
|
+
*
|
|
6
|
+
* This module provides middleware and utilities for integrating
|
|
7
|
+
* odgn-rights with ElysiaJS applications.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { Elysia } from 'elysia';
|
|
12
|
+
* import { elysiaRights } from 'odgn-rights/integrations/elysia';
|
|
13
|
+
*
|
|
14
|
+
* const app = new Elysia()
|
|
15
|
+
* .use(elysiaRights({
|
|
16
|
+
* registry: subjectRegistry,
|
|
17
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
18
|
+
* }))
|
|
19
|
+
* .get('/users', () => 'list users')
|
|
20
|
+
* .listen(3000);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* HTTP method to Flags mapping
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_METHOD_FLAG_MAP = {
|
|
27
|
+
DELETE: 4, // DELETE
|
|
28
|
+
GET: 1, // READ
|
|
29
|
+
HEAD: 1,
|
|
30
|
+
OPTIONS: 1,
|
|
31
|
+
PATCH: 2,
|
|
32
|
+
POST: 8, // CREATE
|
|
33
|
+
PUT: 2 // WRITE
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Create an Elysia plugin for rights-based authorization.
|
|
37
|
+
*
|
|
38
|
+
* @example Using with SubjectRegistry
|
|
39
|
+
* ```typescript
|
|
40
|
+
* import { Elysia } from 'elysia';
|
|
41
|
+
* import { elysiaRights } from 'odgn-rights/integrations/elysia';
|
|
42
|
+
*
|
|
43
|
+
* const app = new Elysia()
|
|
44
|
+
* .use(elysiaRights({
|
|
45
|
+
* registry: subjectRegistry,
|
|
46
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
47
|
+
* }))
|
|
48
|
+
* .get('/users', () => 'list users')
|
|
49
|
+
* .listen(3000);
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @example Using with direct Subject
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const app = new Elysia()
|
|
55
|
+
* .derive(({ headers }) => ({
|
|
56
|
+
* user: getUserFromToken(headers.get('authorization'))
|
|
57
|
+
* }))
|
|
58
|
+
* .use(elysiaRights({
|
|
59
|
+
* getSubject: ({ user }) => user?.subject
|
|
60
|
+
* }))
|
|
61
|
+
* .get('/users', () => 'list users')
|
|
62
|
+
* .listen(3000);
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export const elysiaRights = (options) => {
|
|
66
|
+
// Dynamic import to avoid requiring elysia as a hard dependency
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
|
+
const { Elysia } = require('elysia');
|
|
69
|
+
const { flagMapper, getContext, getSubject, onNoSubject, onUnauthorized, pathMapper, registry } = options;
|
|
70
|
+
return new Elysia({ name: 'odgn-rights' }).onBeforeHandle({ as: 'scoped' }, async ({ path, request, set, ...rest }) => {
|
|
71
|
+
const method = request.method;
|
|
72
|
+
// Get the subject
|
|
73
|
+
const subjectOrId = await getSubject({
|
|
74
|
+
headers: request.headers,
|
|
75
|
+
path,
|
|
76
|
+
request,
|
|
77
|
+
...rest
|
|
78
|
+
});
|
|
79
|
+
let subject;
|
|
80
|
+
if (subjectOrId === null || subjectOrId === undefined) {
|
|
81
|
+
if (onNoSubject) {
|
|
82
|
+
return onNoSubject({ path, request });
|
|
83
|
+
}
|
|
84
|
+
set.status = 401;
|
|
85
|
+
return { error: 'Unauthorized', message: 'No subject found' };
|
|
86
|
+
}
|
|
87
|
+
if (typeof subjectOrId === 'string') {
|
|
88
|
+
if (!registry) {
|
|
89
|
+
throw new Error('elysiaRights: registry is required when getSubject returns a string identifier');
|
|
90
|
+
}
|
|
91
|
+
subject = registry.get(subjectOrId);
|
|
92
|
+
if (!subject) {
|
|
93
|
+
if (onNoSubject) {
|
|
94
|
+
return onNoSubject({ path, request });
|
|
95
|
+
}
|
|
96
|
+
set.status = 401;
|
|
97
|
+
return { error: 'Unauthorized', message: 'Subject not found' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
subject = subjectOrId;
|
|
102
|
+
}
|
|
103
|
+
// Map the path
|
|
104
|
+
const rightsPath = pathMapper
|
|
105
|
+
? pathMapper({ method, path, request })
|
|
106
|
+
: path;
|
|
107
|
+
// Get the required flags
|
|
108
|
+
const requiredFlags = flagMapper
|
|
109
|
+
? flagMapper({ method, path, request })
|
|
110
|
+
: (DEFAULT_METHOD_FLAG_MAP[method] ?? 1); // Default to READ
|
|
111
|
+
// Get condition context if provided
|
|
112
|
+
const context = getContext
|
|
113
|
+
? await getContext({
|
|
114
|
+
headers: request.headers,
|
|
115
|
+
path,
|
|
116
|
+
request,
|
|
117
|
+
subject,
|
|
118
|
+
...rest
|
|
119
|
+
})
|
|
120
|
+
: undefined;
|
|
121
|
+
// Check permission
|
|
122
|
+
const allowed = subject.has(rightsPath, requiredFlags, context);
|
|
123
|
+
if (!allowed) {
|
|
124
|
+
if (onUnauthorized) {
|
|
125
|
+
return onUnauthorized({
|
|
126
|
+
path: rightsPath,
|
|
127
|
+
request,
|
|
128
|
+
requiredFlags,
|
|
129
|
+
subject
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
set.status = 403;
|
|
133
|
+
return { error: 'Forbidden', message: 'Access denied' };
|
|
134
|
+
}
|
|
135
|
+
// Permission granted, continue to handler
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Create an Elysia plugin for rights-based authorization using a Rights instance directly.
|
|
140
|
+
* Use this when you don't need subject/role-based access control.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```typescript
|
|
144
|
+
* import { Elysia } from 'elysia';
|
|
145
|
+
* import { elysiaRightsStandalone } from 'odgn-rights/integrations/elysia';
|
|
146
|
+
* import { Rights, Flags } from 'odgn-rights';
|
|
147
|
+
*
|
|
148
|
+
* const rights = new Rights();
|
|
149
|
+
* rights.allow('/api/**', Flags.READ);
|
|
150
|
+
* rights.allow('/api/admin/**', Flags.ALL);
|
|
151
|
+
*
|
|
152
|
+
* const app = new Elysia()
|
|
153
|
+
* .use(elysiaRightsStandalone({ rights }))
|
|
154
|
+
* .get('/api/users', () => 'list users')
|
|
155
|
+
* .listen(3000);
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export const elysiaRightsStandalone = (options) => {
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
160
|
+
const { Elysia } = require('elysia');
|
|
161
|
+
const { flagMapper, getContext, onUnauthorized, pathMapper, rights } = options;
|
|
162
|
+
return new Elysia({ name: 'odgn-rights-standalone' }).onBeforeHandle({ as: 'scoped' }, async ({ path, request, set, ...rest }) => {
|
|
163
|
+
const method = request.method;
|
|
164
|
+
// Map the path
|
|
165
|
+
const rightsPath = pathMapper
|
|
166
|
+
? pathMapper({ method, path, request })
|
|
167
|
+
: path;
|
|
168
|
+
// Get the required flags
|
|
169
|
+
const requiredFlags = flagMapper
|
|
170
|
+
? flagMapper({ method, path, request })
|
|
171
|
+
: (DEFAULT_METHOD_FLAG_MAP[method] ?? 1);
|
|
172
|
+
// Get condition context if provided
|
|
173
|
+
const context = getContext
|
|
174
|
+
? await getContext({
|
|
175
|
+
headers: request.headers,
|
|
176
|
+
path,
|
|
177
|
+
request,
|
|
178
|
+
subject: undefined,
|
|
179
|
+
...rest
|
|
180
|
+
})
|
|
181
|
+
: undefined;
|
|
182
|
+
// Check permission
|
|
183
|
+
const allowed = rights.has(rightsPath, requiredFlags, context);
|
|
184
|
+
if (!allowed) {
|
|
185
|
+
if (onUnauthorized) {
|
|
186
|
+
return onUnauthorized({
|
|
187
|
+
path: rightsPath,
|
|
188
|
+
request,
|
|
189
|
+
requiredFlags,
|
|
190
|
+
subject: undefined
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
set.status = 403;
|
|
194
|
+
return { error: 'Forbidden', message: 'Access denied' };
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Create a guard configuration for use with Elysia's .guard() method.
|
|
200
|
+
* This allows more fine-grained control over which routes are protected.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* import { Elysia } from 'elysia';
|
|
205
|
+
* import { createRightsGuard } from 'odgn-rights/integrations/elysia';
|
|
206
|
+
*
|
|
207
|
+
* const guard = createRightsGuard({
|
|
208
|
+
* registry: subjectRegistry,
|
|
209
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
210
|
+
* });
|
|
211
|
+
*
|
|
212
|
+
* const app = new Elysia()
|
|
213
|
+
* .get('/public', () => 'anyone can see this')
|
|
214
|
+
* .guard(guard, (app) =>
|
|
215
|
+
* app
|
|
216
|
+
* .get('/protected', () => 'only authorized users')
|
|
217
|
+
* .post('/protected', () => 'create something')
|
|
218
|
+
* )
|
|
219
|
+
* .listen(3000);
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
export const createRightsGuard = (options) => {
|
|
223
|
+
const { flagMapper, getContext, getSubject, onNoSubject, onUnauthorized, pathMapper, registry } = options;
|
|
224
|
+
return {
|
|
225
|
+
async beforeHandle({ path, request, set, ...rest }) {
|
|
226
|
+
const method = request.method;
|
|
227
|
+
const subjectOrId = await getSubject({
|
|
228
|
+
headers: request.headers,
|
|
229
|
+
path,
|
|
230
|
+
request,
|
|
231
|
+
...rest
|
|
232
|
+
});
|
|
233
|
+
let subject;
|
|
234
|
+
if (subjectOrId === null || subjectOrId === undefined) {
|
|
235
|
+
if (onNoSubject) {
|
|
236
|
+
return onNoSubject({ path, request });
|
|
237
|
+
}
|
|
238
|
+
set.status = 401;
|
|
239
|
+
return { error: 'Unauthorized', message: 'No subject found' };
|
|
240
|
+
}
|
|
241
|
+
if (typeof subjectOrId === 'string') {
|
|
242
|
+
if (!registry) {
|
|
243
|
+
throw new Error('createRightsGuard: registry is required when getSubject returns a string identifier');
|
|
244
|
+
}
|
|
245
|
+
subject = registry.get(subjectOrId);
|
|
246
|
+
if (!subject) {
|
|
247
|
+
if (onNoSubject) {
|
|
248
|
+
return onNoSubject({ path, request });
|
|
249
|
+
}
|
|
250
|
+
set.status = 401;
|
|
251
|
+
return { error: 'Unauthorized', message: 'Subject not found' };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
subject = subjectOrId;
|
|
256
|
+
}
|
|
257
|
+
const rightsPath = pathMapper
|
|
258
|
+
? pathMapper({ method, path, request })
|
|
259
|
+
: path;
|
|
260
|
+
const requiredFlags = flagMapper
|
|
261
|
+
? flagMapper({ method, path, request })
|
|
262
|
+
: (DEFAULT_METHOD_FLAG_MAP[method] ?? 1);
|
|
263
|
+
const context = getContext
|
|
264
|
+
? await getContext({
|
|
265
|
+
headers: request.headers,
|
|
266
|
+
path,
|
|
267
|
+
request,
|
|
268
|
+
subject,
|
|
269
|
+
...rest
|
|
270
|
+
})
|
|
271
|
+
: undefined;
|
|
272
|
+
const allowed = subject.has(rightsPath, requiredFlags, context);
|
|
273
|
+
if (!allowed) {
|
|
274
|
+
if (onUnauthorized) {
|
|
275
|
+
return onUnauthorized({
|
|
276
|
+
path: rightsPath,
|
|
277
|
+
request,
|
|
278
|
+
requiredFlags,
|
|
279
|
+
subject
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
set.status = 403;
|
|
283
|
+
return { error: 'Forbidden', message: 'Access denied' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Create a macro for declarative per-route authorization.
|
|
290
|
+
* This allows you to specify rights requirements directly on route definitions.
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```typescript
|
|
294
|
+
* import { Elysia } from 'elysia';
|
|
295
|
+
* import { createRightsMacro } from 'odgn-rights/integrations/elysia';
|
|
296
|
+
* import { Flags } from 'odgn-rights';
|
|
297
|
+
*
|
|
298
|
+
* const rightsMacro = createRightsMacro({
|
|
299
|
+
* registry: subjectRegistry,
|
|
300
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
301
|
+
* });
|
|
302
|
+
*
|
|
303
|
+
* const app = new Elysia()
|
|
304
|
+
* .use(rightsMacro)
|
|
305
|
+
* .get('/public', () => 'anyone')
|
|
306
|
+
* .get('/users', () => 'list users', {
|
|
307
|
+
* rights: { path: '/users', flags: Flags.READ }
|
|
308
|
+
* })
|
|
309
|
+
* .delete('/users/:id', () => 'delete user', {
|
|
310
|
+
* rights: { path: '/users/*', flags: Flags.DELETE }
|
|
311
|
+
* })
|
|
312
|
+
* .listen(3000);
|
|
313
|
+
* ```
|
|
314
|
+
*/
|
|
315
|
+
export const createRightsMacro = (options) => {
|
|
316
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
317
|
+
const { Elysia } = require('elysia');
|
|
318
|
+
const { getContext, getSubject, onNoSubject, onUnauthorized, registry } = options;
|
|
319
|
+
return new Elysia({ name: 'odgn-rights-macro' }).macro({
|
|
320
|
+
rights: (rightsConfig) => ({
|
|
321
|
+
async resolve({ path, request, set, status, ...rest }) {
|
|
322
|
+
const subjectOrId = await getSubject({
|
|
323
|
+
headers: request.headers,
|
|
324
|
+
path,
|
|
325
|
+
request,
|
|
326
|
+
...rest
|
|
327
|
+
});
|
|
328
|
+
let subject;
|
|
329
|
+
if (subjectOrId === null || subjectOrId === undefined) {
|
|
330
|
+
if (onNoSubject) {
|
|
331
|
+
return onNoSubject({ path, request });
|
|
332
|
+
}
|
|
333
|
+
return status(401);
|
|
334
|
+
}
|
|
335
|
+
if (typeof subjectOrId === 'string') {
|
|
336
|
+
if (!registry) {
|
|
337
|
+
throw new Error('createRightsMacro: registry is required when getSubject returns a string identifier');
|
|
338
|
+
}
|
|
339
|
+
subject = registry.get(subjectOrId);
|
|
340
|
+
if (!subject) {
|
|
341
|
+
if (onNoSubject) {
|
|
342
|
+
return onNoSubject({ path, request });
|
|
343
|
+
}
|
|
344
|
+
return status(401);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
subject = subjectOrId;
|
|
349
|
+
}
|
|
350
|
+
const context = getContext
|
|
351
|
+
? await getContext({
|
|
352
|
+
headers: request.headers,
|
|
353
|
+
path,
|
|
354
|
+
request,
|
|
355
|
+
subject,
|
|
356
|
+
...rest
|
|
357
|
+
})
|
|
358
|
+
: undefined;
|
|
359
|
+
const allowed = subject.has(rightsConfig.path, rightsConfig.flags, context);
|
|
360
|
+
if (!allowed) {
|
|
361
|
+
if (onUnauthorized) {
|
|
362
|
+
return onUnauthorized({
|
|
363
|
+
path: rightsConfig.path,
|
|
364
|
+
request,
|
|
365
|
+
requiredFlags: rightsConfig.flags,
|
|
366
|
+
subject
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return status(403);
|
|
370
|
+
}
|
|
371
|
+
return { subject };
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
});
|
|
375
|
+
};
|