@ultimabase/acl 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 +21 -0
- package/README.md +4 -2
- package/dist/acl.d.ts +59 -0
- package/dist/acl.d.ts.map +1 -0
- package/dist/acl.js +155 -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 +39 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +63 -0
- package/package.json +44 -3
- package/src/acl.ts +196 -0
- package/src/index.ts +12 -0
- package/src/plugin.ts +123 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @ultimabase/acl
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- ultimabase@0.1.1
|
|
9
|
+
- @ultimabase/roles@0.1.1
|
|
10
|
+
|
|
11
|
+
## 0.1.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [0e885c4]
|
|
20
|
+
- @ultimabase/roles@0.1.0
|
|
21
|
+
- ultimabase@0.1.0
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ultimabase/acl
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Per-row rights for UltimaBase. Each row stores who reads it and who writes it: by user, by role, or for the public. A query sees only the rows its caller may read, and writes only those it may write.
|
|
4
|
+
|
|
5
|
+
Documentation: [ACL](https://ultimabase.dev/docs/acl).
|
package/dist/acl.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Caller } from 'ultimabase';
|
|
2
|
+
/** `write` is update and delete (D-124). */
|
|
3
|
+
export type Permission = 'read' | 'write';
|
|
4
|
+
/** What a client reads, and may send: the subjects of each permission. */
|
|
5
|
+
export type AclJson = {
|
|
6
|
+
readonly read: readonly string[];
|
|
7
|
+
readonly write: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The ACL a value is, or what it lacks: an `ACL`, or `{ read, write }`
|
|
11
|
+
* listing subjects `*`, `user:<id>` or `role:<id>`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parsed(value: unknown): ACL | string;
|
|
14
|
+
/** Who may read and who may write one row; empty, only Master (D-124). */
|
|
15
|
+
export declare class ACL {
|
|
16
|
+
#private;
|
|
17
|
+
/** Its entries as stored, as `['read:*', 'write:user:u_1']`. */
|
|
18
|
+
static fromEntries(entries: Iterable<string>): ACL;
|
|
19
|
+
/** What `toJSON` gives, or a client sends. */
|
|
20
|
+
static fromJSON(json: unknown): ACL;
|
|
21
|
+
allowPublic(...perms: readonly Permission[]): ACL;
|
|
22
|
+
allowUser(user: {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
}, ...perms: readonly Permission[]): ACL;
|
|
25
|
+
allowRole(role: {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
}, ...perms: readonly Permission[]): ACL;
|
|
28
|
+
denyPublic(...perms: readonly Permission[]): ACL;
|
|
29
|
+
denyUser(user: {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
}, ...perms: readonly Permission[]): ACL;
|
|
32
|
+
denyRole(role: {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
}, ...perms: readonly Permission[]): ACL;
|
|
35
|
+
publicCan(permission: Permission): boolean;
|
|
36
|
+
userCan(user: {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
}, permission: Permission): boolean;
|
|
39
|
+
roleCan(role: {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
}, permission: Permission): boolean;
|
|
42
|
+
/** The ids of the users having the permission. */
|
|
43
|
+
users(permission: Permission): readonly string[];
|
|
44
|
+
/** The ids of the roles having the permission. */
|
|
45
|
+
roles(permission: Permission): readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* Whether the caller has the permission, through the public, its user or
|
|
48
|
+
* one of its roles; a write needs the read too, as the queries do. True
|
|
49
|
+
* for Master.
|
|
50
|
+
*/
|
|
51
|
+
allows(caller: Caller, permission: Permission): boolean;
|
|
52
|
+
/** Its entries as stored, sorted. */
|
|
53
|
+
entries(): readonly string[];
|
|
54
|
+
/** What a client reads: `{ read: ['*'], write: ['user:u_1'] }`. */
|
|
55
|
+
toJSON(): AclJson;
|
|
56
|
+
}
|
|
57
|
+
/** The subjects a caller other than Master is: `*`, its user, its roles. */
|
|
58
|
+
export declare function subjectsOf(caller: Caller): readonly string[];
|
|
59
|
+
//# sourceMappingURL=acl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"acl.d.ts","sourceRoot":"","sources":["../src/acl.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,4CAA4C;AAC5C,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,CAAA;AAEzC,0EAA0E;AAC1E,MAAM,MAAM,OAAO,GAAG;IACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;CACjC,CAAA;AAkBD;;;GAGG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,GAAG,GAAG,MAAM,CAmBnD;AAED,0EAA0E;AAC1E,qBAAa,GAAG;;IASf,gEAAgE;IAChE,MAAM,CAAC,WAAW,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,GAAG,CASjD;IAED,8CAA8C;IAC9C,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,GAAG,CAIlC;IAYD,WAAW,CAAC,GAAG,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,GAAG,CAEhD;IAED,SAAS,CACR,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC7B,GAAG,KAAK,EAAE,SAAS,UAAU,EAAE,GAC7B,GAAG,CAEL;IAED,SAAS,CACR,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC7B,GAAG,KAAK,EAAE,SAAS,UAAU,EAAE,GAC7B,GAAG,CAEL;IAED,UAAU,CAAC,GAAG,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,GAAG,CAE/C;IAED,QAAQ,CACP,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC7B,GAAG,KAAK,EAAE,SAAS,UAAU,EAAE,GAC7B,GAAG,CAEL;IAED,QAAQ,CACP,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC7B,GAAG,KAAK,EAAE,SAAS,UAAU,EAAE,GAC7B,GAAG,CAEL;IAED,SAAS,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAEzC;IAED,OAAO,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO,CAEtE;IAED,OAAO,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO,CAEtE;IAED,kDAAkD;IAClD,KAAK,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAE/C;IAED,kDAAkD;IAClD,KAAK,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAE/C;IAQD;;;;OAIG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO,CAMtD;IAED,qCAAqC;IACrC,OAAO,IAAI,SAAS,MAAM,EAAE,CAE3B;IAED,mEAAmE;IACnE,MAAM,IAAI,OAAO,CAMhB;CACD;AAED,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAO5D"}
|
package/dist/acl.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
// The `ACL` class (D-124): who may read and who may write one row, as
|
|
3
|
+
// entries `read:*`, `read:user:<id>` and `write:role:<id>`. Each method
|
|
4
|
+
// returns a new ACL.
|
|
5
|
+
import { rolesOf } from '@ultimabase/roles';
|
|
6
|
+
const permissions = ['read', 'write'];
|
|
7
|
+
const isSubject = (subject) => subject === '*' || /^(?:user|role):.+$/u.test(subject);
|
|
8
|
+
/** The entry's permission, or `undefined` when it is not an entry. */
|
|
9
|
+
function permissionOf(entry) {
|
|
10
|
+
const permission = permissions.find((each) => entry.startsWith(`${each}:`));
|
|
11
|
+
if (permission === undefined)
|
|
12
|
+
return undefined;
|
|
13
|
+
return isSubject(entry.slice(permission.length + 1)) ? permission : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** The permissions given, or both when none is. */
|
|
16
|
+
const given = (perms) => perms.length === 0 ? permissions : perms;
|
|
17
|
+
/**
|
|
18
|
+
* The ACL a value is, or what it lacks: an `ACL`, or `{ read, write }`
|
|
19
|
+
* listing subjects `*`, `user:<id>` or `role:<id>`.
|
|
20
|
+
*/
|
|
21
|
+
export function parsed(value) {
|
|
22
|
+
if (value instanceof ACL)
|
|
23
|
+
return value;
|
|
24
|
+
if (typeof value !== 'object' || value === null) {
|
|
25
|
+
return 'An ACL is an ACL or { read, write }.';
|
|
26
|
+
}
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const key of Object.keys(value)) {
|
|
29
|
+
const permission = permissions.find((each) => each === key);
|
|
30
|
+
if (permission === undefined)
|
|
31
|
+
return `${key} is not a permission.`;
|
|
32
|
+
const subjects = Reflect.get(value, key);
|
|
33
|
+
if (!Array.isArray(subjects))
|
|
34
|
+
return `${key} is a list of subjects.`;
|
|
35
|
+
for (const [index, subject] of subjects.entries()) {
|
|
36
|
+
if (typeof subject !== 'string' || !isSubject(subject)) {
|
|
37
|
+
return `${key}[${index}]: a subject is *, user:<id> or role:<id>.`;
|
|
38
|
+
}
|
|
39
|
+
entries.push(`${permission}:${subject}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return ACL.fromEntries(entries);
|
|
43
|
+
}
|
|
44
|
+
/** Who may read and who may write one row; empty, only Master (D-124). */
|
|
45
|
+
export class ACL {
|
|
46
|
+
#entries = new Set();
|
|
47
|
+
static #of(entries) {
|
|
48
|
+
const acl = new _a();
|
|
49
|
+
acl.#entries = entries;
|
|
50
|
+
return acl;
|
|
51
|
+
}
|
|
52
|
+
/** Its entries as stored, as `['read:*', 'write:user:u_1']`. */
|
|
53
|
+
static fromEntries(entries) {
|
|
54
|
+
const kept = new Set();
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (permissionOf(entry) === undefined) {
|
|
57
|
+
throw new TypeError(`${entry} is not an ACL entry.`);
|
|
58
|
+
}
|
|
59
|
+
kept.add(entry);
|
|
60
|
+
}
|
|
61
|
+
return _a.#of(kept);
|
|
62
|
+
}
|
|
63
|
+
/** What `toJSON` gives, or a client sends. */
|
|
64
|
+
static fromJSON(json) {
|
|
65
|
+
const acl = parsed(json);
|
|
66
|
+
if (typeof acl === 'string')
|
|
67
|
+
throw new TypeError(acl);
|
|
68
|
+
return acl;
|
|
69
|
+
}
|
|
70
|
+
#changed(subject, perms, add) {
|
|
71
|
+
const entries = new Set(this.#entries);
|
|
72
|
+
for (const permission of given(perms)) {
|
|
73
|
+
const entry = `${permission}:${subject}`;
|
|
74
|
+
if (add)
|
|
75
|
+
entries.add(entry);
|
|
76
|
+
else
|
|
77
|
+
entries.delete(entry);
|
|
78
|
+
}
|
|
79
|
+
return _a.#of(entries);
|
|
80
|
+
}
|
|
81
|
+
allowPublic(...perms) {
|
|
82
|
+
return this.#changed('*', perms, true);
|
|
83
|
+
}
|
|
84
|
+
allowUser(user, ...perms) {
|
|
85
|
+
return this.#changed(`user:${user.id}`, perms, true);
|
|
86
|
+
}
|
|
87
|
+
allowRole(role, ...perms) {
|
|
88
|
+
return this.#changed(`role:${role.id}`, perms, true);
|
|
89
|
+
}
|
|
90
|
+
denyPublic(...perms) {
|
|
91
|
+
return this.#changed('*', perms, false);
|
|
92
|
+
}
|
|
93
|
+
denyUser(user, ...perms) {
|
|
94
|
+
return this.#changed(`user:${user.id}`, perms, false);
|
|
95
|
+
}
|
|
96
|
+
denyRole(role, ...perms) {
|
|
97
|
+
return this.#changed(`role:${role.id}`, perms, false);
|
|
98
|
+
}
|
|
99
|
+
publicCan(permission) {
|
|
100
|
+
return this.#entries.has(`${permission}:*`);
|
|
101
|
+
}
|
|
102
|
+
userCan(user, permission) {
|
|
103
|
+
return this.#entries.has(`${permission}:user:${user.id}`);
|
|
104
|
+
}
|
|
105
|
+
roleCan(role, permission) {
|
|
106
|
+
return this.#entries.has(`${permission}:role:${role.id}`);
|
|
107
|
+
}
|
|
108
|
+
/** The ids of the users having the permission. */
|
|
109
|
+
users(permission) {
|
|
110
|
+
return this.#ids(`${permission}:user:`);
|
|
111
|
+
}
|
|
112
|
+
/** The ids of the roles having the permission. */
|
|
113
|
+
roles(permission) {
|
|
114
|
+
return this.#ids(`${permission}:role:`);
|
|
115
|
+
}
|
|
116
|
+
#ids(prefix) {
|
|
117
|
+
return this.entries()
|
|
118
|
+
.filter((entry) => entry.startsWith(prefix))
|
|
119
|
+
.map((entry) => entry.slice(prefix.length));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether the caller has the permission, through the public, its user or
|
|
123
|
+
* one of its roles; a write needs the read too, as the queries do. True
|
|
124
|
+
* for Master.
|
|
125
|
+
*/
|
|
126
|
+
allows(caller, permission) {
|
|
127
|
+
if (caller.kind === 'master')
|
|
128
|
+
return true;
|
|
129
|
+
const keys = subjectsOf(caller);
|
|
130
|
+
const has = (wanted) => keys.some((key) => this.#entries.has(`${wanted}:${key}`));
|
|
131
|
+
return has('read') && (permission === 'read' || has('write'));
|
|
132
|
+
}
|
|
133
|
+
/** Its entries as stored, sorted. */
|
|
134
|
+
entries() {
|
|
135
|
+
return [...this.#entries].toSorted();
|
|
136
|
+
}
|
|
137
|
+
/** What a client reads: `{ read: ['*'], write: ['user:u_1'] }`. */
|
|
138
|
+
toJSON() {
|
|
139
|
+
const subjects = (permission) => this.entries()
|
|
140
|
+
.filter((entry) => permissionOf(entry) === permission)
|
|
141
|
+
.map((entry) => entry.slice(permission.length + 1));
|
|
142
|
+
return { read: subjects('read'), write: subjects('write') };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
_a = ACL;
|
|
146
|
+
/** The subjects a caller other than Master is: `*`, its user, its roles. */
|
|
147
|
+
export function subjectsOf(caller) {
|
|
148
|
+
if (caller.kind !== 'user')
|
|
149
|
+
return ['*'];
|
|
150
|
+
return [
|
|
151
|
+
'*',
|
|
152
|
+
`user:${caller.user.id}`,
|
|
153
|
+
...rolesOf(caller).map(({ id }) => `role:${id}`),
|
|
154
|
+
];
|
|
155
|
+
}
|
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,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AACnD,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAC9B,YAAY,EACX,GAAG,EACH,UAAU,EACV,QAAQ,EACR,QAAQ,EACR,UAAU,GACV,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Caller, Field, ModelUse, PluginOf, PluginTypes } from 'ultimabase';
|
|
2
|
+
import type { AclJson } from './acl.ts';
|
|
3
|
+
import { ACL } from './acl.ts';
|
|
4
|
+
/** What an `acl` field takes: an `ACL`, or its JSON. */
|
|
5
|
+
export type AclInput = ACL | AclJson;
|
|
6
|
+
/** The ACL of a row created without one, from the caller (D-124). */
|
|
7
|
+
export type AclDefault = (facts: {
|
|
8
|
+
readonly caller: Caller;
|
|
9
|
+
}) => ACL;
|
|
10
|
+
export type AclOptions = {
|
|
11
|
+
readonly default: AclDefault;
|
|
12
|
+
};
|
|
13
|
+
type Stored = Field<readonly string[], {
|
|
14
|
+
readonly list: true;
|
|
15
|
+
}>;
|
|
16
|
+
/** The `acl` field a model that uses the plugin gets. */
|
|
17
|
+
export type AclField = Field<ACL, {
|
|
18
|
+
readonly list: true;
|
|
19
|
+
readonly default: AclDefault;
|
|
20
|
+
}, 'acl', never, AclInput, true, Stored>;
|
|
21
|
+
/** The ACL plugin's types (D-57). */
|
|
22
|
+
export interface Acl extends PluginTypes {
|
|
23
|
+
readonly name: 'acl';
|
|
24
|
+
readonly options: AclOptions;
|
|
25
|
+
readonly addedFields: {
|
|
26
|
+
readonly acl: AclField;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** The ACL plugin, which `plugins` lists. */
|
|
30
|
+
export declare const aclPlugin: PluginOf<Acl>;
|
|
31
|
+
/**
|
|
32
|
+
* A model's per-row rights: an `acl` field, `default` giving the ACL of a
|
|
33
|
+
* row created without one (D-124).
|
|
34
|
+
*/
|
|
35
|
+
export declare function acl(options: AclOptions): ModelUse<'acl', Acl & {
|
|
36
|
+
readonly options: AclOptions;
|
|
37
|
+
}>;
|
|
38
|
+
export {};
|
|
39
|
+
//# 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,KAAK,EAEL,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,MAAM,YAAY,CAAA;AAGnB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA;AACvC,OAAO,EAAE,GAAG,EAAsB,MAAM,UAAU,CAAA;AAElD,wDAAwD;AACxD,MAAM,MAAM,QAAQ,GAAG,GAAG,GAAG,OAAO,CAAA;AAEpC,qEAAqE;AACrE,MAAM,MAAM,UAAU,GAAG,CAAC,KAAK,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,KAAK,GAAG,CAAA;AAEpE,MAAM,MAAM,UAAU,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAAE,CAAA;AAEzD,KAAK,MAAM,GAAG,KAAK,CAAC,SAAS,MAAM,EAAE,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,CAAC,CAAA;AAE/D,yDAAyD;AACzD,MAAM,MAAM,QAAQ,GAAG,KAAK,CAC3B,GAAG,EACH;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAAE,EACrD,KAAK,EACL,KAAK,EACL,QAAQ,EACR,IAAI,EACJ,MAAM,CACN,CAAA;AAED,qCAAqC;AACrC,MAAM,WAAW,GAAI,SAAQ,WAAW;IACvC,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;IACpB,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;IAC5B,QAAQ,CAAC,WAAW,EAAE;QAAE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAA;KAAE,CAAA;CAChD;AAqED,6CAA6C;AAC7C,eAAO,MAAM,SAAS,EAAE,QAAQ,CAAC,GAAG,CAAqB,CAAA;AAEzD;;;GAGG;AACH,wBAAgB,GAAG,CAClB,OAAO,EAAE,UAAU,GACjB,QAAQ,CAAC,KAAK,EAAE,GAAG,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAAE,CAAC,CAEzD"}
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineFieldType, definePlugin, Refused } from 'ultimabase';
|
|
2
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
3
|
+
import { ACL, parsed, subjectsOf } from './acl.js';
|
|
4
|
+
/** A Standard Schema taking an `ACL` or `{ read, write }`. */
|
|
5
|
+
const schema = {
|
|
6
|
+
'~standard': {
|
|
7
|
+
version: 1,
|
|
8
|
+
vendor: 'ultimabase-acl',
|
|
9
|
+
validate: (value) => {
|
|
10
|
+
const given = parsed(value);
|
|
11
|
+
return typeof given === 'string'
|
|
12
|
+
? { issues: [{ message: given }] }
|
|
13
|
+
: { value: given };
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const aclType = defineFieldType({
|
|
18
|
+
plugin: 'acl',
|
|
19
|
+
stored: (f) => f.string({ list: true }),
|
|
20
|
+
decode: (stored) => ACL.fromEntries(stored),
|
|
21
|
+
encode: (rights) => rights.entries(),
|
|
22
|
+
schema,
|
|
23
|
+
toJSON: (rights) => rights.toJSON(),
|
|
24
|
+
});
|
|
25
|
+
/** The keys the caller's subjects give for a permission. */
|
|
26
|
+
const keys = (caller, permission) => subjectsOf(caller).map((subject) => `${permission}:${subject}`);
|
|
27
|
+
const definition = definePlugin({
|
|
28
|
+
name: 'acl',
|
|
29
|
+
manifest,
|
|
30
|
+
addedFields: (_f, options) => {
|
|
31
|
+
const fallback = Reflect.get(Object(options), 'default');
|
|
32
|
+
if (typeof fallback !== 'function') {
|
|
33
|
+
throw new Refused('acl() takes default: the ACL of a row created without one.');
|
|
34
|
+
}
|
|
35
|
+
// Only Master writes an ACL: sharing is a function's (D-124).
|
|
36
|
+
return {
|
|
37
|
+
acl: aclType({ default: options.default }).with({
|
|
38
|
+
access: { create: () => false, update: () => false },
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
addedIndexes: [{ fields: ['acl'] }],
|
|
43
|
+
hooks: {
|
|
44
|
+
beforeFind: ({ caller, operation, where }) => {
|
|
45
|
+
if (caller.kind === 'master')
|
|
46
|
+
return undefined;
|
|
47
|
+
const read = { acl: { hasSome: keys(caller, 'read') } };
|
|
48
|
+
if (operation === 'read')
|
|
49
|
+
return where(read);
|
|
50
|
+
// A caller writes the rows it reads.
|
|
51
|
+
return where({ AND: [read, { acl: { hasSome: keys(caller, 'write') } }] });
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
/** The ACL plugin, which `plugins` lists. */
|
|
56
|
+
export const aclPlugin = definition.plugin;
|
|
57
|
+
/**
|
|
58
|
+
* A model's per-row rights: an `acl` field, `default` giving the ACL of a
|
|
59
|
+
* row created without one (D-124).
|
|
60
|
+
*/
|
|
61
|
+
export function acl(options) {
|
|
62
|
+
return definition.use(options);
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/acl",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Per-row rights for UltimaBase: who may read and write each row, as Parse's ACL.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/acl",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/acl"
|
|
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/roles": "^0.1.1",
|
|
35
|
+
"ultimabase": "^0.1.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/pg": "8.23.1",
|
|
39
|
+
"@ultimabase/postgres": "0.1.1",
|
|
40
|
+
"@ultimabase/roles": "0.1.1",
|
|
41
|
+
"@ultimabase/testing": "0.1.1",
|
|
42
|
+
"pg": "8.23.1",
|
|
43
|
+
"ultimabase": "0.1.1",
|
|
44
|
+
"vite": "8.3.4",
|
|
45
|
+
"vitest": "5.0.5"
|
|
46
|
+
}
|
|
6
47
|
}
|
package/src/acl.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The `ACL` class (D-124): who may read and who may write one row, as
|
|
2
|
+
// entries `read:*`, `read:user:<id>` and `write:role:<id>`. Each method
|
|
3
|
+
// returns a new ACL.
|
|
4
|
+
import { rolesOf } from '@ultimabase/roles'
|
|
5
|
+
import type { Caller } from 'ultimabase'
|
|
6
|
+
|
|
7
|
+
/** `write` is update and delete (D-124). */
|
|
8
|
+
export type Permission = 'read' | 'write'
|
|
9
|
+
|
|
10
|
+
/** What a client reads, and may send: the subjects of each permission. */
|
|
11
|
+
export type AclJson = {
|
|
12
|
+
readonly read: readonly string[]
|
|
13
|
+
readonly write: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const permissions: readonly Permission[] = ['read', 'write']
|
|
17
|
+
|
|
18
|
+
const isSubject = (subject: string): boolean =>
|
|
19
|
+
subject === '*' || /^(?:user|role):.+$/u.test(subject)
|
|
20
|
+
|
|
21
|
+
/** The entry's permission, or `undefined` when it is not an entry. */
|
|
22
|
+
function permissionOf(entry: string): Permission | undefined {
|
|
23
|
+
const permission = permissions.find((each) => entry.startsWith(`${each}:`))
|
|
24
|
+
if (permission === undefined) return undefined
|
|
25
|
+
return isSubject(entry.slice(permission.length + 1)) ? permission : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The permissions given, or both when none is. */
|
|
29
|
+
const given = (perms: readonly Permission[]): readonly Permission[] =>
|
|
30
|
+
perms.length === 0 ? permissions : perms
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ACL a value is, or what it lacks: an `ACL`, or `{ read, write }`
|
|
34
|
+
* listing subjects `*`, `user:<id>` or `role:<id>`.
|
|
35
|
+
*/
|
|
36
|
+
export function parsed(value: unknown): ACL | string {
|
|
37
|
+
if (value instanceof ACL) return value
|
|
38
|
+
if (typeof value !== 'object' || value === null) {
|
|
39
|
+
return 'An ACL is an ACL or { read, write }.'
|
|
40
|
+
}
|
|
41
|
+
const entries: string[] = []
|
|
42
|
+
for (const key of Object.keys(value)) {
|
|
43
|
+
const permission = permissions.find((each) => each === key)
|
|
44
|
+
if (permission === undefined) return `${key} is not a permission.`
|
|
45
|
+
const subjects: unknown = Reflect.get(value, key)
|
|
46
|
+
if (!Array.isArray(subjects)) return `${key} is a list of subjects.`
|
|
47
|
+
for (const [index, subject] of subjects.entries()) {
|
|
48
|
+
if (typeof subject !== 'string' || !isSubject(subject)) {
|
|
49
|
+
return `${key}[${index}]: a subject is *, user:<id> or role:<id>.`
|
|
50
|
+
}
|
|
51
|
+
entries.push(`${permission}:${subject}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return ACL.fromEntries(entries)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Who may read and who may write one row; empty, only Master (D-124). */
|
|
58
|
+
export class ACL {
|
|
59
|
+
#entries: ReadonlySet<string> = new Set()
|
|
60
|
+
|
|
61
|
+
static #of(entries: ReadonlySet<string>): ACL {
|
|
62
|
+
const acl = new ACL()
|
|
63
|
+
acl.#entries = entries
|
|
64
|
+
return acl
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Its entries as stored, as `['read:*', 'write:user:u_1']`. */
|
|
68
|
+
static fromEntries(entries: Iterable<string>): ACL {
|
|
69
|
+
const kept = new Set<string>()
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (permissionOf(entry) === undefined) {
|
|
72
|
+
throw new TypeError(`${entry} is not an ACL entry.`)
|
|
73
|
+
}
|
|
74
|
+
kept.add(entry)
|
|
75
|
+
}
|
|
76
|
+
return ACL.#of(kept)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What `toJSON` gives, or a client sends. */
|
|
80
|
+
static fromJSON(json: unknown): ACL {
|
|
81
|
+
const acl = parsed(json)
|
|
82
|
+
if (typeof acl === 'string') throw new TypeError(acl)
|
|
83
|
+
return acl
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#changed(subject: string, perms: readonly Permission[], add: boolean): ACL {
|
|
87
|
+
const entries = new Set(this.#entries)
|
|
88
|
+
for (const permission of given(perms)) {
|
|
89
|
+
const entry = `${permission}:${subject}`
|
|
90
|
+
if (add) entries.add(entry)
|
|
91
|
+
else entries.delete(entry)
|
|
92
|
+
}
|
|
93
|
+
return ACL.#of(entries)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
allowPublic(...perms: readonly Permission[]): ACL {
|
|
97
|
+
return this.#changed('*', perms, true)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
allowUser(
|
|
101
|
+
user: { readonly id: string },
|
|
102
|
+
...perms: readonly Permission[]
|
|
103
|
+
): ACL {
|
|
104
|
+
return this.#changed(`user:${user.id}`, perms, true)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
allowRole(
|
|
108
|
+
role: { readonly id: string },
|
|
109
|
+
...perms: readonly Permission[]
|
|
110
|
+
): ACL {
|
|
111
|
+
return this.#changed(`role:${role.id}`, perms, true)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
denyPublic(...perms: readonly Permission[]): ACL {
|
|
115
|
+
return this.#changed('*', perms, false)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
denyUser(
|
|
119
|
+
user: { readonly id: string },
|
|
120
|
+
...perms: readonly Permission[]
|
|
121
|
+
): ACL {
|
|
122
|
+
return this.#changed(`user:${user.id}`, perms, false)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
denyRole(
|
|
126
|
+
role: { readonly id: string },
|
|
127
|
+
...perms: readonly Permission[]
|
|
128
|
+
): ACL {
|
|
129
|
+
return this.#changed(`role:${role.id}`, perms, false)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
publicCan(permission: Permission): boolean {
|
|
133
|
+
return this.#entries.has(`${permission}:*`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
userCan(user: { readonly id: string }, permission: Permission): boolean {
|
|
137
|
+
return this.#entries.has(`${permission}:user:${user.id}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
roleCan(role: { readonly id: string }, permission: Permission): boolean {
|
|
141
|
+
return this.#entries.has(`${permission}:role:${role.id}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The ids of the users having the permission. */
|
|
145
|
+
users(permission: Permission): readonly string[] {
|
|
146
|
+
return this.#ids(`${permission}:user:`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The ids of the roles having the permission. */
|
|
150
|
+
roles(permission: Permission): readonly string[] {
|
|
151
|
+
return this.#ids(`${permission}:role:`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#ids(prefix: string): readonly string[] {
|
|
155
|
+
return this.entries()
|
|
156
|
+
.filter((entry) => entry.startsWith(prefix))
|
|
157
|
+
.map((entry) => entry.slice(prefix.length))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether the caller has the permission, through the public, its user or
|
|
162
|
+
* one of its roles; a write needs the read too, as the queries do. True
|
|
163
|
+
* for Master.
|
|
164
|
+
*/
|
|
165
|
+
allows(caller: Caller, permission: Permission): boolean {
|
|
166
|
+
if (caller.kind === 'master') return true
|
|
167
|
+
const keys = subjectsOf(caller)
|
|
168
|
+
const has = (wanted: Permission) =>
|
|
169
|
+
keys.some((key) => this.#entries.has(`${wanted}:${key}`))
|
|
170
|
+
return has('read') && (permission === 'read' || has('write'))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Its entries as stored, sorted. */
|
|
174
|
+
entries(): readonly string[] {
|
|
175
|
+
return [...this.#entries].toSorted()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What a client reads: `{ read: ['*'], write: ['user:u_1'] }`. */
|
|
179
|
+
toJSON(): AclJson {
|
|
180
|
+
const subjects = (permission: Permission) =>
|
|
181
|
+
this.entries()
|
|
182
|
+
.filter((entry) => permissionOf(entry) === permission)
|
|
183
|
+
.map((entry) => entry.slice(permission.length + 1))
|
|
184
|
+
return { read: subjects('read'), write: subjects('write') }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The subjects a caller other than Master is: `*`, its user, its roles. */
|
|
189
|
+
export function subjectsOf(caller: Caller): readonly string[] {
|
|
190
|
+
if (caller.kind !== 'user') return ['*']
|
|
191
|
+
return [
|
|
192
|
+
'*',
|
|
193
|
+
`user:${caller.user.id}`,
|
|
194
|
+
...rolesOf(caller).map(({ id }) => `role:${id}`),
|
|
195
|
+
]
|
|
196
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// `@ultimabase/acl` (D-124): who may read and who may write each row, stored
|
|
2
|
+
// in the row and filtered in its query.
|
|
3
|
+
export type { AclJson, Permission } from './acl.ts'
|
|
4
|
+
export { ACL } from './acl.ts'
|
|
5
|
+
export type {
|
|
6
|
+
Acl,
|
|
7
|
+
AclDefault,
|
|
8
|
+
AclField,
|
|
9
|
+
AclInput,
|
|
10
|
+
AclOptions,
|
|
11
|
+
} from './plugin.ts'
|
|
12
|
+
export { acl, aclPlugin } from './plugin.ts'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `aclPlugin()` and `acl({ default })` (D-124): an `acl` field on each model
|
|
2
|
+
// that uses it, which only Master writes, and a beforeFind keeping the rows
|
|
3
|
+
// the caller may read, or write too for the rows a write targets.
|
|
4
|
+
import type {
|
|
5
|
+
Caller,
|
|
6
|
+
Field,
|
|
7
|
+
FieldType,
|
|
8
|
+
ModelUse,
|
|
9
|
+
PluginOf,
|
|
10
|
+
PluginTypes,
|
|
11
|
+
} from 'ultimabase'
|
|
12
|
+
import { defineFieldType, definePlugin, Refused } from 'ultimabase'
|
|
13
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
14
|
+
import type { AclJson } from './acl.ts'
|
|
15
|
+
import { ACL, parsed, subjectsOf } from './acl.ts'
|
|
16
|
+
|
|
17
|
+
/** What an `acl` field takes: an `ACL`, or its JSON. */
|
|
18
|
+
export type AclInput = ACL | AclJson
|
|
19
|
+
|
|
20
|
+
/** The ACL of a row created without one, from the caller (D-124). */
|
|
21
|
+
export type AclDefault = (facts: { readonly caller: Caller }) => ACL
|
|
22
|
+
|
|
23
|
+
export type AclOptions = { readonly default: AclDefault }
|
|
24
|
+
|
|
25
|
+
type Stored = Field<readonly string[], { readonly list: true }>
|
|
26
|
+
|
|
27
|
+
/** The `acl` field a model that uses the plugin gets. */
|
|
28
|
+
export type AclField = Field<
|
|
29
|
+
ACL,
|
|
30
|
+
{ readonly list: true; readonly default: AclDefault },
|
|
31
|
+
'acl',
|
|
32
|
+
never,
|
|
33
|
+
AclInput,
|
|
34
|
+
true,
|
|
35
|
+
Stored
|
|
36
|
+
>
|
|
37
|
+
|
|
38
|
+
/** The ACL plugin's types (D-57). */
|
|
39
|
+
export interface Acl extends PluginTypes {
|
|
40
|
+
readonly name: 'acl'
|
|
41
|
+
readonly options: AclOptions
|
|
42
|
+
readonly addedFields: { readonly acl: AclField }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Result =
|
|
46
|
+
| { readonly value: ACL }
|
|
47
|
+
| { readonly issues: readonly { readonly message: string }[] }
|
|
48
|
+
|
|
49
|
+
/** A Standard Schema taking an `ACL` or `{ read, write }`. */
|
|
50
|
+
const schema: {
|
|
51
|
+
readonly '~standard': {
|
|
52
|
+
readonly version: 1
|
|
53
|
+
readonly vendor: string
|
|
54
|
+
readonly validate: (value: unknown) => Result
|
|
55
|
+
readonly types?: { readonly input: AclInput; readonly output: ACL }
|
|
56
|
+
}
|
|
57
|
+
} = {
|
|
58
|
+
'~standard': {
|
|
59
|
+
version: 1,
|
|
60
|
+
vendor: 'ultimabase-acl',
|
|
61
|
+
validate: (value) => {
|
|
62
|
+
const given = parsed(value)
|
|
63
|
+
return typeof given === 'string'
|
|
64
|
+
? { issues: [{ message: given }] }
|
|
65
|
+
: { value: given }
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const aclType: FieldType<'acl', Stored, ACL, AclInput> = defineFieldType({
|
|
71
|
+
plugin: 'acl',
|
|
72
|
+
stored: (f) => f.string({ list: true }),
|
|
73
|
+
decode: (stored) => ACL.fromEntries(stored),
|
|
74
|
+
encode: (rights) => rights.entries(),
|
|
75
|
+
schema,
|
|
76
|
+
toJSON: (rights) => rights.toJSON(),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/** The keys the caller's subjects give for a permission. */
|
|
80
|
+
const keys = (caller: Caller, permission: 'read' | 'write') =>
|
|
81
|
+
subjectsOf(caller).map((subject) => `${permission}:${subject}`)
|
|
82
|
+
|
|
83
|
+
const definition = definePlugin<Acl>({
|
|
84
|
+
name: 'acl',
|
|
85
|
+
manifest,
|
|
86
|
+
addedFields: (_f, options) => {
|
|
87
|
+
const fallback: unknown = Reflect.get(Object(options), 'default')
|
|
88
|
+
if (typeof fallback !== 'function') {
|
|
89
|
+
throw new Refused(
|
|
90
|
+
'acl() takes default: the ACL of a row created without one.',
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
// Only Master writes an ACL: sharing is a function's (D-124).
|
|
94
|
+
return {
|
|
95
|
+
acl: aclType({ default: options.default }).with({
|
|
96
|
+
access: { create: () => false, update: () => false },
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
addedIndexes: [{ fields: ['acl'] }],
|
|
101
|
+
hooks: {
|
|
102
|
+
beforeFind: ({ caller, operation, where }) => {
|
|
103
|
+
if (caller.kind === 'master') return undefined
|
|
104
|
+
const read = { acl: { hasSome: keys(caller, 'read') } }
|
|
105
|
+
if (operation === 'read') return where(read)
|
|
106
|
+
// A caller writes the rows it reads.
|
|
107
|
+
return where({ AND: [read, { acl: { hasSome: keys(caller, 'write') } }] })
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** The ACL plugin, which `plugins` lists. */
|
|
113
|
+
export const aclPlugin: PluginOf<Acl> = definition.plugin
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A model's per-row rights: an `acl` field, `default` giving the ACL of a
|
|
117
|
+
* row created without one (D-124).
|
|
118
|
+
*/
|
|
119
|
+
export function acl(
|
|
120
|
+
options: AclOptions,
|
|
121
|
+
): ModelUse<'acl', Acl & { readonly options: AclOptions }> {
|
|
122
|
+
return definition.use(options)
|
|
123
|
+
}
|