@ultimabase/roles 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -2
- package/dist/define-role.d.ts +42 -0
- package/dist/define-role.d.ts.map +1 -0
- package/dist/define-role.js +36 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/plugin.d.ts +27 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +115 -0
- package/package.json +42 -3
- package/src/define-role.ts +128 -0
- package/src/index.ts +6 -0
- package/src/plugin.ts +175 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @ultimabase/roles
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- ultimabase@0.1.1
|
|
9
|
+
|
|
10
|
+
## 0.1.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [0e885c4]
|
|
19
|
+
- ultimabase@0.1.0
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ultimabase/roles
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Roles for UltimaBase: users get roles, such as `writer` or `team`, roles hold other roles, and the access rule `hasRole` asks whether a caller has one.
|
|
4
|
+
|
|
5
|
+
Documentation: [Roles](https://ultimabase.dev/docs/roles).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Field, FieldShapes, IndexShape, Many, ModelFields, ModelIndexes, ModelOf, ModelUse, PluginTypes } from 'ultimabase';
|
|
2
|
+
import type { Roles } from './plugin.ts';
|
|
3
|
+
type Uses = readonly ModelUse<string, PluginTypes>[];
|
|
4
|
+
/** The role model's `use`: roles first, then the developer's plugins. */
|
|
5
|
+
type WithRoles<U extends Uses> = readonly [ModelUse<'roles', Roles>, ...U];
|
|
6
|
+
/** The fields `defineRole` adds; `R` is the role model's own thunk. */
|
|
7
|
+
export type RoleFields<M, R> = {
|
|
8
|
+
readonly name: Field<string, {
|
|
9
|
+
readonly unique: true;
|
|
10
|
+
}>;
|
|
11
|
+
readonly members: Many<M, {
|
|
12
|
+
readonly inverse?: string;
|
|
13
|
+
}>;
|
|
14
|
+
readonly inherits: Many<R, {
|
|
15
|
+
readonly inverse: 'inheritedBy';
|
|
16
|
+
}>;
|
|
17
|
+
readonly inheritedBy: Many<R, {
|
|
18
|
+
readonly inverse: 'inherits';
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
/** The role model, typed through an interface: it refers to itself. */
|
|
22
|
+
export interface RoleThunk<N extends string, F, M, U extends Uses, X extends readonly IndexShape[]> {
|
|
23
|
+
(): ModelOf<N, F & RoleFields<M, RoleThunk<N, F, M, U, X>>, WithRoles<U>, X>;
|
|
24
|
+
}
|
|
25
|
+
/** What every role model declares; `F` types `indexes` only. */
|
|
26
|
+
type RoleDefinition<N, M, F, U extends Uses, X> = {
|
|
27
|
+
readonly name: N;
|
|
28
|
+
/** The stored table's or collection's name (D-63). */
|
|
29
|
+
readonly dbName?: string;
|
|
30
|
+
/** The user model, `() => User`. */
|
|
31
|
+
readonly members: M;
|
|
32
|
+
readonly use?: U;
|
|
33
|
+
readonly indexes?: ModelIndexes<X, F & RoleFields<M, () => object>, WithRoles<U>>;
|
|
34
|
+
};
|
|
35
|
+
/** The application's one role model, with its own fields (D-123). */
|
|
36
|
+
export declare function defineRole<const N extends string, const M extends () => object, const F extends FieldShapes, const U extends Uses = [], const X extends readonly IndexShape[] = []>(definition: RoleDefinition<N, M, F, U, X> & {
|
|
37
|
+
readonly fields: ModelFields<F, WithRoles<U>>;
|
|
38
|
+
}): ReturnType<RoleThunk<N, F, M, U, X>>;
|
|
39
|
+
/** The application's one role model, without fields of its own (D-123). */
|
|
40
|
+
export declare function defineRole<const N extends string, const M extends () => object, const U extends Uses = [], const X extends readonly IndexShape[] = []>(definition: RoleDefinition<N, M, {}, U, X>): ReturnType<RoleThunk<N, {}, M, U, X>>;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=define-role.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"define-role.d.ts","sourceRoot":"","sources":["../src/define-role.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineModel, Refused } from 'ultimabase';
|
|
2
|
+
import { roleModels, rolesUse } from './plugin.js';
|
|
3
|
+
const reserved = ['name', 'members', 'inherits', 'inheritedBy'];
|
|
4
|
+
export function defineRole(definition) {
|
|
5
|
+
const { name, members, fields } = definition;
|
|
6
|
+
if (typeof members !== 'function') {
|
|
7
|
+
throw new Refused(`defineRole(${name}): members is () => the user model.`);
|
|
8
|
+
}
|
|
9
|
+
const self = () => model;
|
|
10
|
+
const model = defineModel({
|
|
11
|
+
name,
|
|
12
|
+
...(definition.dbName === undefined ? {} : { dbName: definition.dbName }),
|
|
13
|
+
use: [rolesUse(), ...(definition.use ?? [])],
|
|
14
|
+
fields: (f) => {
|
|
15
|
+
// The developer's fields, typed by its own `use`.
|
|
16
|
+
const own = fields === undefined ? {} : Reflect.apply(fields, undefined, [f]);
|
|
17
|
+
if (typeof own !== 'object' || own === null) {
|
|
18
|
+
throw new Refused(`defineRole(${name}): fields returns an object.`);
|
|
19
|
+
}
|
|
20
|
+
const taken = reserved.find((key) => Object.hasOwn(own, key));
|
|
21
|
+
if (taken !== undefined) {
|
|
22
|
+
throw new Refused(`defineRole(${name}): ${taken} is a field defineRole adds.`);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...own,
|
|
26
|
+
name: f.string({ unique: true }),
|
|
27
|
+
members: f.many(members),
|
|
28
|
+
inherits: f.many(self, { inverse: 'inheritedBy' }),
|
|
29
|
+
inheritedBy: f.many(self, { inverse: 'inherits' }),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
indexes: definition.indexes ?? [],
|
|
33
|
+
});
|
|
34
|
+
roleModels.add(model);
|
|
35
|
+
return model;
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,YAAY,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Caller, CallerRule, PluginOf, PluginTypes, UseOf } from 'ultimabase';
|
|
2
|
+
/** The roles plugin's types (D-57). */
|
|
3
|
+
export interface Roles extends PluginTypes {
|
|
4
|
+
readonly name: 'roles';
|
|
5
|
+
}
|
|
6
|
+
/** The models `defineRole` returned. */
|
|
7
|
+
export declare const roleModels: WeakSet<object>;
|
|
8
|
+
/** Each user caller's roles, read once per caller (D-123). */
|
|
9
|
+
export declare const rolesPlugin: PluginOf<Roles>;
|
|
10
|
+
/** The roles plugin's entry in the role model's `use`, which `defineRole` adds. */
|
|
11
|
+
export declare const rolesUse: UseOf<Roles>;
|
|
12
|
+
/**
|
|
13
|
+
* The caller's roles, each `{ id, name }`, inherited ones included; `[]` for
|
|
14
|
+
* a caller the roles plugin did not complete (D-126).
|
|
15
|
+
*/
|
|
16
|
+
export declare function rolesOf(caller: Caller): readonly {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
}[];
|
|
20
|
+
/**
|
|
21
|
+
* Whether the caller has one of the roles, inherited ones included; false
|
|
22
|
+
* for an anonymous or Master caller (D-126). Without the caller, the rule
|
|
23
|
+
* asking it (D-10).
|
|
24
|
+
*/
|
|
25
|
+
export declare function hasRole(...names: readonly [string, ...string[]]): CallerRule;
|
|
26
|
+
export declare function hasRole(caller: Caller, ...names: readonly [string, ...string[]]): boolean;
|
|
27
|
+
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,MAAM,EACN,UAAU,EAGV,QAAQ,EACR,WAAW,EACX,KAAK,EACL,MAAM,YAAY,CAAA;AAInB,uCAAuC;AACvC,MAAM,WAAW,KAAM,SAAQ,WAAW;IACzC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CACtB;AAID,wCAAwC;AACxC,eAAO,MAAM,UAAU,EAAE,OAAO,CAAC,MAAM,CAAiB,CAAA;AAwGxD,8DAA8D;AAC9D,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,KAAK,CAAgB,CAAA;AACxD,mFAAmF;AACnF,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAa,CAAA;AAE/C;;;GAGG;AACH,wBAAgB,OAAO,CACtB,MAAM,EAAE,MAAM,GACZ,SAAS;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAG3D;AAaD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,UAAU,CAAA;AAC7E,wBAAgB,OAAO,CACtB,MAAM,EAAE,MAAM,EACd,GAAG,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GACtC,OAAO,CAAA"}
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { definePlugin, master, Refused } from 'ultimabase';
|
|
2
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
3
|
+
/** The models `defineRole` returned. */
|
|
4
|
+
export const roleModels = new WeakSet();
|
|
5
|
+
/** The caller's key holding its roles, which `@ultimabase/roles` keeps (D-126). */
|
|
6
|
+
const rolesKey = Symbol('roles');
|
|
7
|
+
/** The lists of roles this plugin gave a caller. */
|
|
8
|
+
const given = new WeakSet();
|
|
9
|
+
const M = { as: master };
|
|
10
|
+
/** The one role model among the application's models (D-123). */
|
|
11
|
+
function rolesModelOf(models) {
|
|
12
|
+
const [first, second] = models.filter(({ queries }) => roleModels.has(queries));
|
|
13
|
+
if (first === undefined) {
|
|
14
|
+
throw new Refused('rolesPlugin() needs a role model: declare it with defineRole.');
|
|
15
|
+
}
|
|
16
|
+
if (second !== undefined) {
|
|
17
|
+
throw new Refused('models lists two models defineRole returned: an application has one role model.');
|
|
18
|
+
}
|
|
19
|
+
return first.queries;
|
|
20
|
+
}
|
|
21
|
+
/** The levels of inheritance one read follows (D-123). */
|
|
22
|
+
const depth = 5;
|
|
23
|
+
/** A role's id and name, and its inherited roles `left` levels deep. */
|
|
24
|
+
const level = (left) => ({
|
|
25
|
+
id: true,
|
|
26
|
+
name: true,
|
|
27
|
+
inherits: { select: left === 0 ? { id: true } : level(left - 1) },
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Adds a read role and the roles it inherits to `held`; a role read past
|
|
31
|
+
* the last level, its id alone, goes to `missing`. A role already held was
|
|
32
|
+
* followed, which ends a cycle.
|
|
33
|
+
*/
|
|
34
|
+
function follow(row, held, missing) {
|
|
35
|
+
const id = Reflect.get(Object(row), 'id');
|
|
36
|
+
const name = Reflect.get(Object(row), 'name');
|
|
37
|
+
if (typeof id !== 'string' || held.has(id))
|
|
38
|
+
return;
|
|
39
|
+
if (typeof name !== 'string') {
|
|
40
|
+
missing.add(id);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
held.set(id, Object.freeze({ id, name }));
|
|
44
|
+
const parents = Reflect.get(Object(row), 'inherits');
|
|
45
|
+
for (const parent of Array.isArray(parents) ? parents : []) {
|
|
46
|
+
follow(parent, held, missing);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every role of a user: the roles it is a member of and those they inherit,
|
|
51
|
+
* five levels per query (D-123).
|
|
52
|
+
*/
|
|
53
|
+
async function rolesOfUser(id, queries) {
|
|
54
|
+
const held = new Map();
|
|
55
|
+
const select = level(depth);
|
|
56
|
+
let where = { members: { some: { id } } };
|
|
57
|
+
for (;;) {
|
|
58
|
+
const missing = new Set();
|
|
59
|
+
for (const row of await queries.findMany({ where, select }, M)) {
|
|
60
|
+
follow(row, held, missing);
|
|
61
|
+
}
|
|
62
|
+
const next = [...missing].filter((role) => !held.has(role));
|
|
63
|
+
if (next.length === 0)
|
|
64
|
+
break;
|
|
65
|
+
where = { id: { in: next } };
|
|
66
|
+
}
|
|
67
|
+
const roles = Object.freeze([...held.values()]);
|
|
68
|
+
given.add(roles);
|
|
69
|
+
return roles;
|
|
70
|
+
}
|
|
71
|
+
const isHeld = (value) => typeof value === 'object' && value !== null && given.has(value);
|
|
72
|
+
const roles = definePlugin({
|
|
73
|
+
name: 'roles',
|
|
74
|
+
manifest,
|
|
75
|
+
start: ({ models }) => {
|
|
76
|
+
rolesModelOf(models);
|
|
77
|
+
},
|
|
78
|
+
caller: async ({ user }, { models }) => {
|
|
79
|
+
// Built apart: a key no interface declares is not an excess one.
|
|
80
|
+
const keys = {
|
|
81
|
+
[rolesKey]: await rolesOfUser(user.id, rolesModelOf(models)),
|
|
82
|
+
};
|
|
83
|
+
return keys;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
/** Each user caller's roles, read once per caller (D-123). */
|
|
87
|
+
export const rolesPlugin = roles.plugin;
|
|
88
|
+
/** The roles plugin's entry in the role model's `use`, which `defineRole` adds. */
|
|
89
|
+
export const rolesUse = roles.use;
|
|
90
|
+
/**
|
|
91
|
+
* The caller's roles, each `{ id, name }`, inherited ones included; `[]` for
|
|
92
|
+
* a caller the roles plugin did not complete (D-126).
|
|
93
|
+
*/
|
|
94
|
+
export function rolesOf(caller) {
|
|
95
|
+
const held = Reflect.get(caller, rolesKey);
|
|
96
|
+
return isHeld(held) ? held : [];
|
|
97
|
+
}
|
|
98
|
+
function holds(caller, names) {
|
|
99
|
+
if (caller.kind !== 'user')
|
|
100
|
+
return false;
|
|
101
|
+
const held = Reflect.get(caller, rolesKey);
|
|
102
|
+
if (!isHeld(held)) {
|
|
103
|
+
throw new Error('hasRole reads the roles rolesPlugin() gives a caller: add rolesPlugin() to plugins.');
|
|
104
|
+
}
|
|
105
|
+
return held.some(({ name }) => names.includes(name));
|
|
106
|
+
}
|
|
107
|
+
export function hasRole(first, ...names) {
|
|
108
|
+
if (typeof first !== 'string') {
|
|
109
|
+
if (names.length === 0)
|
|
110
|
+
throw new TypeError('hasRole takes a role name.');
|
|
111
|
+
return holds(first, names);
|
|
112
|
+
}
|
|
113
|
+
const wanted = [first, ...names];
|
|
114
|
+
return ({ caller }) => holds(caller, wanted);
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/roles",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Roles for UltimaBase: a role model, roles inside roles, and the rule asking a caller's role.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/roles",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/roles"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24.7.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/test"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"ultimabase": "^0.1.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/pg": "8.23.1",
|
|
38
|
+
"@ultimabase/postgres": "0.1.1",
|
|
39
|
+
"@ultimabase/testing": "0.1.1",
|
|
40
|
+
"pg": "8.23.1",
|
|
41
|
+
"ultimabase": "0.1.1",
|
|
42
|
+
"vite": "8.3.4",
|
|
43
|
+
"vitest": "5.0.5"
|
|
44
|
+
}
|
|
6
45
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// `defineRole` (D-13, D-123): the application's one role model, an ordinary
|
|
2
|
+
// model with a unique name, its members, and the roles it inherits.
|
|
3
|
+
import type {
|
|
4
|
+
Field,
|
|
5
|
+
FieldShapes,
|
|
6
|
+
IndexShape,
|
|
7
|
+
Many,
|
|
8
|
+
ModelFields,
|
|
9
|
+
ModelIndexes,
|
|
10
|
+
ModelOf,
|
|
11
|
+
ModelUse,
|
|
12
|
+
PluginTypes,
|
|
13
|
+
} from 'ultimabase'
|
|
14
|
+
import { defineModel, Refused } from 'ultimabase'
|
|
15
|
+
import type { Roles } from './plugin.ts'
|
|
16
|
+
import { roleModels, rolesUse } from './plugin.ts'
|
|
17
|
+
|
|
18
|
+
type Uses = readonly ModelUse<string, PluginTypes>[]
|
|
19
|
+
/** The role model's `use`: roles first, then the developer's plugins. */
|
|
20
|
+
type WithRoles<U extends Uses> = readonly [ModelUse<'roles', Roles>, ...U]
|
|
21
|
+
|
|
22
|
+
/** The fields `defineRole` adds; `R` is the role model's own thunk. */
|
|
23
|
+
export type RoleFields<M, R> = {
|
|
24
|
+
readonly name: Field<string, { readonly unique: true }>
|
|
25
|
+
readonly members: Many<M, { readonly inverse?: string }>
|
|
26
|
+
readonly inherits: Many<R, { readonly inverse: 'inheritedBy' }>
|
|
27
|
+
readonly inheritedBy: Many<R, { readonly inverse: 'inherits' }>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The role model, typed through an interface: it refers to itself. */
|
|
31
|
+
export interface RoleThunk<
|
|
32
|
+
N extends string,
|
|
33
|
+
F,
|
|
34
|
+
M,
|
|
35
|
+
U extends Uses,
|
|
36
|
+
X extends readonly IndexShape[],
|
|
37
|
+
> {
|
|
38
|
+
(): ModelOf<N, F & RoleFields<M, RoleThunk<N, F, M, U, X>>, WithRoles<U>, X>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reserved = ['name', 'members', 'inherits', 'inheritedBy']
|
|
42
|
+
|
|
43
|
+
/** What every role model declares; `F` types `indexes` only. */
|
|
44
|
+
type RoleDefinition<N, M, F, U extends Uses, X> = {
|
|
45
|
+
readonly name: N
|
|
46
|
+
/** The stored table's or collection's name (D-63). */
|
|
47
|
+
readonly dbName?: string
|
|
48
|
+
/** The user model, `() => User`. */
|
|
49
|
+
readonly members: M
|
|
50
|
+
readonly use?: U
|
|
51
|
+
// Checked on the scalar fields only: the role model's own type would
|
|
52
|
+
// infer `X` from itself.
|
|
53
|
+
readonly indexes?: ModelIndexes<
|
|
54
|
+
X,
|
|
55
|
+
F & RoleFields<M, () => object>,
|
|
56
|
+
WithRoles<U>
|
|
57
|
+
>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The application's one role model, with its own fields (D-123). */
|
|
61
|
+
export function defineRole<
|
|
62
|
+
const N extends string,
|
|
63
|
+
const M extends () => object,
|
|
64
|
+
const F extends FieldShapes,
|
|
65
|
+
const U extends Uses = [],
|
|
66
|
+
const X extends readonly IndexShape[] = [],
|
|
67
|
+
>(
|
|
68
|
+
definition: RoleDefinition<N, M, F, U, X> & {
|
|
69
|
+
readonly fields: ModelFields<F, WithRoles<U>>
|
|
70
|
+
},
|
|
71
|
+
): ReturnType<RoleThunk<N, F, M, U, X>>
|
|
72
|
+
/** The application's one role model, without fields of its own (D-123). */
|
|
73
|
+
export function defineRole<
|
|
74
|
+
const N extends string,
|
|
75
|
+
const M extends () => object,
|
|
76
|
+
const U extends Uses = [],
|
|
77
|
+
const X extends readonly IndexShape[] = [],
|
|
78
|
+
>(
|
|
79
|
+
definition: RoleDefinition<N, M, {}, U, X>,
|
|
80
|
+
): ReturnType<RoleThunk<N, {}, M, U, X>>
|
|
81
|
+
export function defineRole(definition: {
|
|
82
|
+
readonly name: string
|
|
83
|
+
readonly dbName?: string
|
|
84
|
+
readonly members: unknown
|
|
85
|
+
readonly use?: Uses
|
|
86
|
+
readonly fields?: Function
|
|
87
|
+
readonly indexes?: readonly IndexShape[]
|
|
88
|
+
}): object {
|
|
89
|
+
const { name, members, fields } = definition
|
|
90
|
+
if (typeof members !== 'function') {
|
|
91
|
+
throw new Refused(`defineRole(${name}): members is () => the user model.`)
|
|
92
|
+
}
|
|
93
|
+
const self = (): object => model
|
|
94
|
+
const model: object = defineModel<
|
|
95
|
+
string,
|
|
96
|
+
FieldShapes,
|
|
97
|
+
Uses,
|
|
98
|
+
readonly IndexShape[]
|
|
99
|
+
>({
|
|
100
|
+
name,
|
|
101
|
+
...(definition.dbName === undefined ? {} : { dbName: definition.dbName }),
|
|
102
|
+
use: [rolesUse(), ...(definition.use ?? [])],
|
|
103
|
+
fields: (f) => {
|
|
104
|
+
// The developer's fields, typed by its own `use`.
|
|
105
|
+
const own: unknown =
|
|
106
|
+
fields === undefined ? {} : Reflect.apply(fields, undefined, [f])
|
|
107
|
+
if (typeof own !== 'object' || own === null) {
|
|
108
|
+
throw new Refused(`defineRole(${name}): fields returns an object.`)
|
|
109
|
+
}
|
|
110
|
+
const taken = reserved.find((key) => Object.hasOwn(own, key))
|
|
111
|
+
if (taken !== undefined) {
|
|
112
|
+
throw new Refused(
|
|
113
|
+
`defineRole(${name}): ${taken} is a field defineRole adds.`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...own,
|
|
118
|
+
name: f.string({ unique: true }),
|
|
119
|
+
members: f.many(members),
|
|
120
|
+
inherits: f.many(self, { inverse: 'inheritedBy' }),
|
|
121
|
+
inheritedBy: f.many(self, { inverse: 'inherits' }),
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
indexes: definition.indexes ?? [],
|
|
125
|
+
})
|
|
126
|
+
roleModels.add(model)
|
|
127
|
+
return model
|
|
128
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `@ultimabase/roles` (D-123, D-126): the role model, roles inside roles, and
|
|
2
|
+
// the question whether a caller has a role.
|
|
3
|
+
export type { RoleFields, RoleThunk } from './define-role.ts'
|
|
4
|
+
export { defineRole } from './define-role.ts'
|
|
5
|
+
export type { Roles } from './plugin.ts'
|
|
6
|
+
export { hasRole, rolesOf, rolesPlugin } from './plugin.ts'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// `rolesPlugin()` (D-123, D-126): each user caller completed with every role
|
|
2
|
+
// the user has, inherited ones included, under a key no other code names;
|
|
3
|
+
// `hasRole` asks it, `rolesOf` lists it.
|
|
4
|
+
import type {
|
|
5
|
+
Caller,
|
|
6
|
+
CallerRule,
|
|
7
|
+
ExposedModel,
|
|
8
|
+
ModelQueries,
|
|
9
|
+
PluginOf,
|
|
10
|
+
PluginTypes,
|
|
11
|
+
UseOf,
|
|
12
|
+
} from 'ultimabase'
|
|
13
|
+
import { definePlugin, master, Refused } from 'ultimabase'
|
|
14
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
15
|
+
|
|
16
|
+
/** The roles plugin's types (D-57). */
|
|
17
|
+
export interface Roles extends PluginTypes {
|
|
18
|
+
readonly name: 'roles'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Held = { readonly id: string; readonly name: string }
|
|
22
|
+
|
|
23
|
+
/** The models `defineRole` returned. */
|
|
24
|
+
export const roleModels: WeakSet<object> = new WeakSet()
|
|
25
|
+
|
|
26
|
+
/** The caller's key holding its roles, which `@ultimabase/roles` keeps (D-126). */
|
|
27
|
+
const rolesKey = Symbol('roles')
|
|
28
|
+
/** The lists of roles this plugin gave a caller. */
|
|
29
|
+
const given = new WeakSet<object>()
|
|
30
|
+
|
|
31
|
+
const M = { as: master }
|
|
32
|
+
|
|
33
|
+
/** The one role model among the application's models (D-123). */
|
|
34
|
+
function rolesModelOf(models: readonly ExposedModel[]): ModelQueries {
|
|
35
|
+
const [first, second] = models.filter(({ queries }) =>
|
|
36
|
+
roleModels.has(queries),
|
|
37
|
+
)
|
|
38
|
+
if (first === undefined) {
|
|
39
|
+
throw new Refused(
|
|
40
|
+
'rolesPlugin() needs a role model: declare it with defineRole.',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
if (second !== undefined) {
|
|
44
|
+
throw new Refused(
|
|
45
|
+
'models lists two models defineRole returned: an application has one role model.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return first.queries
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The levels of inheritance one read follows (D-123). */
|
|
52
|
+
const depth = 5
|
|
53
|
+
|
|
54
|
+
/** A role's id and name, and its inherited roles `left` levels deep. */
|
|
55
|
+
const level = (left: number): object => ({
|
|
56
|
+
id: true,
|
|
57
|
+
name: true,
|
|
58
|
+
inherits: { select: left === 0 ? { id: true } : level(left - 1) },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Adds a read role and the roles it inherits to `held`; a role read past
|
|
63
|
+
* the last level, its id alone, goes to `missing`. A role already held was
|
|
64
|
+
* followed, which ends a cycle.
|
|
65
|
+
*/
|
|
66
|
+
function follow(
|
|
67
|
+
row: unknown,
|
|
68
|
+
held: Map<string, Held>,
|
|
69
|
+
missing: Set<string>,
|
|
70
|
+
): void {
|
|
71
|
+
const id: unknown = Reflect.get(Object(row), 'id')
|
|
72
|
+
const name: unknown = Reflect.get(Object(row), 'name')
|
|
73
|
+
if (typeof id !== 'string' || held.has(id)) return
|
|
74
|
+
if (typeof name !== 'string') {
|
|
75
|
+
missing.add(id)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
held.set(id, Object.freeze({ id, name }))
|
|
79
|
+
const parents: unknown = Reflect.get(Object(row), 'inherits')
|
|
80
|
+
for (const parent of Array.isArray(parents) ? parents : []) {
|
|
81
|
+
follow(parent, held, missing)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every role of a user: the roles it is a member of and those they inherit,
|
|
87
|
+
* five levels per query (D-123).
|
|
88
|
+
*/
|
|
89
|
+
async function rolesOfUser(
|
|
90
|
+
id: string,
|
|
91
|
+
queries: ModelQueries,
|
|
92
|
+
): Promise<readonly Held[]> {
|
|
93
|
+
const held = new Map<string, Held>()
|
|
94
|
+
const select = level(depth)
|
|
95
|
+
let where: object = { members: { some: { id } } }
|
|
96
|
+
for (;;) {
|
|
97
|
+
const missing = new Set<string>()
|
|
98
|
+
for (const row of await queries.findMany({ where, select }, M)) {
|
|
99
|
+
follow(row, held, missing)
|
|
100
|
+
}
|
|
101
|
+
const next = [...missing].filter((role) => !held.has(role))
|
|
102
|
+
if (next.length === 0) break
|
|
103
|
+
where = { id: { in: next } }
|
|
104
|
+
}
|
|
105
|
+
const roles = Object.freeze([...held.values()])
|
|
106
|
+
given.add(roles)
|
|
107
|
+
return roles
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isHeld = (value: unknown): value is readonly Held[] =>
|
|
111
|
+
typeof value === 'object' && value !== null && given.has(value)
|
|
112
|
+
|
|
113
|
+
const roles = definePlugin<Roles>({
|
|
114
|
+
name: 'roles',
|
|
115
|
+
manifest,
|
|
116
|
+
start: ({ models }) => {
|
|
117
|
+
rolesModelOf(models)
|
|
118
|
+
},
|
|
119
|
+
caller: async ({ user }, { models }) => {
|
|
120
|
+
// Built apart: a key no interface declares is not an excess one.
|
|
121
|
+
const keys = {
|
|
122
|
+
[rolesKey]: await rolesOfUser(user.id, rolesModelOf(models)),
|
|
123
|
+
}
|
|
124
|
+
return keys
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/** Each user caller's roles, read once per caller (D-123). */
|
|
129
|
+
export const rolesPlugin: PluginOf<Roles> = roles.plugin
|
|
130
|
+
/** The roles plugin's entry in the role model's `use`, which `defineRole` adds. */
|
|
131
|
+
export const rolesUse: UseOf<Roles> = roles.use
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The caller's roles, each `{ id, name }`, inherited ones included; `[]` for
|
|
135
|
+
* a caller the roles plugin did not complete (D-126).
|
|
136
|
+
*/
|
|
137
|
+
export function rolesOf(
|
|
138
|
+
caller: Caller,
|
|
139
|
+
): readonly { readonly id: string; readonly name: string }[] {
|
|
140
|
+
const held: unknown = Reflect.get(caller, rolesKey)
|
|
141
|
+
return isHeld(held) ? held : []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function holds(caller: Caller, names: readonly string[]): boolean {
|
|
145
|
+
if (caller.kind !== 'user') return false
|
|
146
|
+
const held: unknown = Reflect.get(caller, rolesKey)
|
|
147
|
+
if (!isHeld(held)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'hasRole reads the roles rolesPlugin() gives a caller: add rolesPlugin() to plugins.',
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return held.some(({ name }) => names.includes(name))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether the caller has one of the roles, inherited ones included; false
|
|
157
|
+
* for an anonymous or Master caller (D-126). Without the caller, the rule
|
|
158
|
+
* asking it (D-10).
|
|
159
|
+
*/
|
|
160
|
+
export function hasRole(...names: readonly [string, ...string[]]): CallerRule
|
|
161
|
+
export function hasRole(
|
|
162
|
+
caller: Caller,
|
|
163
|
+
...names: readonly [string, ...string[]]
|
|
164
|
+
): boolean
|
|
165
|
+
export function hasRole(
|
|
166
|
+
first: Caller | string,
|
|
167
|
+
...names: readonly string[]
|
|
168
|
+
): CallerRule | boolean {
|
|
169
|
+
if (typeof first !== 'string') {
|
|
170
|
+
if (names.length === 0) throw new TypeError('hasRole takes a role name.')
|
|
171
|
+
return holds(first, names)
|
|
172
|
+
}
|
|
173
|
+
const wanted = [first, ...names]
|
|
174
|
+
return ({ caller }) => holds(caller, wanted)
|
|
175
|
+
}
|