odgn-rights 0.2.0 → 0.5.1

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.
@@ -0,0 +1,174 @@
1
+ import { Flags } from '../constants';
2
+ import type { Right } from '../right';
3
+ import type { Rights } from '../rights';
4
+ import type { Role } from '../role';
5
+ import type { RoleRegistry } from '../role-registry';
6
+ import type { Subject } from '../subject';
7
+ export type RightsRow = {
8
+ allow_mask: number;
9
+ created_at: string;
10
+ deny_mask: number;
11
+ description: string | null;
12
+ id: number;
13
+ path: string;
14
+ priority: number;
15
+ tags: string | null;
16
+ updated_at: string;
17
+ valid_from: string | null;
18
+ valid_until: string | null;
19
+ };
20
+ export type RoleRow = {
21
+ created_at: string;
22
+ id: number;
23
+ name: string;
24
+ updated_at: string;
25
+ };
26
+ export type RoleRightRow = {
27
+ right_id: number;
28
+ role_id: number;
29
+ };
30
+ export type RoleInheritanceRow = {
31
+ child_role_id: number;
32
+ parent_role_id: number;
33
+ };
34
+ export type SubjectRow = {
35
+ created_at: string;
36
+ id: number;
37
+ identifier: string;
38
+ updated_at: string;
39
+ };
40
+ export type SubjectRoleRow = {
41
+ role_id: number;
42
+ subject_id: number;
43
+ };
44
+ export type SubjectRightRow = {
45
+ right_id: number;
46
+ subject_id: number;
47
+ };
48
+ /**
49
+ * Base configuration shared by all adapters
50
+ */
51
+ export type BaseAdapterOptions = {
52
+ /**
53
+ * Prefix for all table names. Defaults to 'tbl_'.
54
+ * Set to empty string '' for no prefix.
55
+ */
56
+ tablePrefix?: string;
57
+ };
58
+ /**
59
+ * Table names with the configured prefix applied
60
+ */
61
+ export type TableNames = {
62
+ rights: string;
63
+ roleInheritance: string;
64
+ roleRights: string;
65
+ roles: string;
66
+ subjectRights: string;
67
+ subjectRoles: string;
68
+ subjects: string;
69
+ };
70
+ /**
71
+ * Common interface for all database adapters.
72
+ * Supports both synchronous (SQLite) and asynchronous (PostgreSQL) patterns
73
+ * by using Promise-based methods throughout.
74
+ */
75
+ export type DatabaseAdapter = {
76
+ /**
77
+ * Clear all data from the database (useful for testing)
78
+ */
79
+ clear(): Promise<void>;
80
+ /**
81
+ * Connect to the database
82
+ */
83
+ connect(): Promise<void>;
84
+ /**
85
+ * Delete a right by its database ID
86
+ * @returns true if the right was deleted, false if not found
87
+ */
88
+ deleteRight(id: number): Promise<boolean>;
89
+ /**
90
+ * Delete a role by name
91
+ * @returns true if the role was deleted, false if not found
92
+ */
93
+ deleteRole(name: string): Promise<boolean>;
94
+ /**
95
+ * Delete a subject by its external identifier
96
+ * @returns true if the subject was deleted, false if not found
97
+ */
98
+ deleteSubject(identifier: string): Promise<boolean>;
99
+ /**
100
+ * Disconnect from the database
101
+ */
102
+ disconnect(): Promise<void>;
103
+ /**
104
+ * Find all subject identifiers that have access to a specific path with given flags
105
+ * @param pathPattern The path pattern to check (supports wildcards)
106
+ * @param flags The flags to check for
107
+ * @returns Array of subject identifiers that have access
108
+ */
109
+ findSubjectsWithAccess(pathPattern: string, flags: Flags): Promise<string[]>;
110
+ /**
111
+ * Load all roles into a new RoleRegistry
112
+ */
113
+ loadRegistry(): Promise<RoleRegistry>;
114
+ /**
115
+ * Load a right by its database ID
116
+ */
117
+ loadRight(id: number): Promise<Right | null>;
118
+ /**
119
+ * Load all rights from the database
120
+ */
121
+ loadRights(): Promise<Rights>;
122
+ /**
123
+ * Load rights matching a path pattern
124
+ */
125
+ loadRightsByPath(pathPattern: string): Promise<Rights>;
126
+ /**
127
+ * Load a role by name
128
+ */
129
+ loadRole(name: string): Promise<Role | null>;
130
+ /**
131
+ * Load all roles from the database
132
+ */
133
+ loadRoles(): Promise<Role[]>;
134
+ /**
135
+ * Load a subject by its external identifier
136
+ */
137
+ loadSubject(identifier: string): Promise<Subject | null>;
138
+ /**
139
+ * Run database migrations to create or update schema
140
+ */
141
+ migrate(): Promise<void>;
142
+ /**
143
+ * Save an entire role registry to the database
144
+ */
145
+ saveRegistry(registry: RoleRegistry): Promise<void>;
146
+ /**
147
+ * Save a single right to the database
148
+ * @returns The database ID of the saved right
149
+ */
150
+ saveRight(right: Right): Promise<number>;
151
+ /**
152
+ * Save multiple rights to the database
153
+ * @returns Array of database IDs for the saved rights
154
+ */
155
+ saveRights(rights: Rights): Promise<number[]>;
156
+ /**
157
+ * Save a role and its rights to the database
158
+ * @returns The database ID of the saved role
159
+ */
160
+ saveRole(role: Role): Promise<number>;
161
+ /**
162
+ * Save a subject with its roles and direct rights
163
+ * @param identifier External identifier for the subject (e.g., user ID)
164
+ * @param subject The subject to save
165
+ * @returns The database ID of the saved subject
166
+ */
167
+ saveSubject(identifier: string, subject: Subject): Promise<number>;
168
+ /**
169
+ * Execute operations within a transaction.
170
+ * The transaction will be committed if the function succeeds,
171
+ * or rolled back if it throws an error.
172
+ */
173
+ transaction<T>(fn: (adapter: DatabaseAdapter) => Promise<T>): Promise<T>;
174
+ };
@@ -0,0 +1 @@
1
+ import { Flags } from '../constants';
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable no-console */
2
2
  import { Command } from 'commander';
3
- import { lettersFromMask } from '../../utils';
3
+ import { lettersFromMask } from '@/helpers';
4
4
  import { loadConfig } from '../helpers/config-loader';
5
5
  import { parseFlags } from '../helpers/flag-parser';
6
6
  import { colors, flagName, formatResult } from '../helpers/output';
@@ -30,7 +30,9 @@ export const explainCommand = new Command('explain')
30
30
  details: explanation.details.map(d => ({
31
31
  allowed: d.allowed,
32
32
  flag: flagName(d.bit),
33
- rule: d.right?.toString()
33
+ priority: d.right?.priority,
34
+ rule: d.right?.toString(),
35
+ specificity: d.right?.specificity()
34
36
  })),
35
37
  flag: options.flag,
36
38
  path: options.path
@@ -55,12 +57,18 @@ export const explainCommand = new Command('explain')
55
57
  // Show all matching rules
56
58
  const allRights = rights.allRights().filter(r => r.matches(options.path));
57
59
  if (allRights.length > 0) {
58
- console.log(`\n${colors.bold('Matching rules (by specificity):')}`);
60
+ console.log(`\n${colors.bold('Matching rules (by priority, then specificity):')}`);
59
61
  allRights
60
- .sort((a, b) => b.specificity() - a.specificity())
62
+ .sort((a, b) => {
63
+ const pDiff = b.priority - a.priority;
64
+ if (pDiff !== 0) {
65
+ return pDiff;
66
+ }
67
+ return b.specificity() - a.specificity();
68
+ })
61
69
  .forEach((r, i) => {
62
70
  console.log(` ${i + 1}. ${colors.cyan(r.toString())}`);
63
- console.log(` Specificity: ${r.specificity()}`);
71
+ console.log(` Priority: ${r.priority}, Specificity: ${r.specificity()}`);
64
72
  if (r.tags.length > 0) {
65
73
  console.log(` Tags: ${r.tags.join(', ')}`);
66
74
  }
@@ -0,0 +1,16 @@
1
+ export type ParsedPath = {
2
+ negated: boolean;
3
+ path: string;
4
+ };
5
+ /**
6
+ * Parse a path string, detecting and stripping negation prefix.
7
+ * Handles double negation (!! becomes positive).
8
+ *
9
+ * @example
10
+ * parsePath('!/api/internal') // { path: '/api/internal', negated: true }
11
+ * parsePath('/api/public') // { path: '/api/public', negated: false }
12
+ * parsePath('!!/api/path') // { path: '/api/path', negated: false }
13
+ */
14
+ export declare const parsePath: (p: string) => ParsedPath;
15
+ export declare const normalizePath: (p: string) => string;
16
+ export declare const lettersFromMask: (mask: number) => string;
@@ -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
@@ -4,4 +4,6 @@ export * from './rights';
4
4
  export * from './role';
5
5
  export * from './role-registry';
6
6
  export * from './subject';
7
- export * from './utils';
7
+ export * from './subject-registry';
8
+ export * from './helpers';
9
+ export * from './adapters';
package/dist/index.js CHANGED
@@ -4,4 +4,6 @@ export * from './rights';
4
4
  export * from './role';
5
5
  export * from './role-registry';
6
6
  export * from './subject';
7
- export * from './utils';
7
+ export * from './subject-registry';
8
+ export * from './helpers';
9
+ export * from './adapters';
@@ -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 {};