@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 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
- # Temporary Holding Version
1
+ # @ultimabase/acl
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -0,0 +1,5 @@
1
+ export type { AclJson, Permission } from './acl.ts';
2
+ export { ACL } from './acl.ts';
3
+ export type { Acl, AclDefault, AclField, AclInput, AclOptions, } from './plugin.ts';
4
+ export { acl, aclPlugin } from './plugin.ts';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -0,0 +1,2 @@
1
+ export { ACL } from './acl.js';
2
+ export { acl, aclPlugin } from './plugin.js';
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }