@procedures/access 0.0.0-stage → 0.12.2

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/dist/access.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The rights of a subject on a scope. A scope is a name of an app, for example `space`,
3
+ * `board:b1` or `user:u1`. A scope can have an access list: an owner, and entries that give
4
+ * rights to principals. An entry can have a `when` condition over the document of the check.
5
+ *
6
+ * The app gives its own rules as a policy: the rights that hold without a list (the owner of a
7
+ * personal scope), the parent of a scope without a list, and the rights of a scope without a list
8
+ * and without a parent. The engine does the rest.
9
+ */
10
+ /** The default model: `admin` implies `share` and `write`, and each of them implies `read`. */
11
+ export const DEFAULT_RIGHTS = Object.freeze({
12
+ all: Object.freeze(["read", "write", "share", "admin"]),
13
+ implies: Object.freeze({ admin: Object.freeze(["share", "write"]), share: Object.freeze(["read"]), write: Object.freeze(["read"]) }),
14
+ });
15
+ const normalEmail = (email) => (email ?? "").trim().toLowerCase();
16
+ /** The engine of the rights of a policy. */
17
+ export class Access {
18
+ #policy;
19
+ #model;
20
+ constructor(policy = {}) {
21
+ this.#policy = policy;
22
+ this.#model = policy.rights ?? DEFAULT_RIGHTS;
23
+ }
24
+ /** This method gives the rights and each right that they imply. It ignores a right that the model does not know. */
25
+ implied(rights) {
26
+ const out = new Set();
27
+ const add = (right) => {
28
+ if (out.has(right) || !this.#model.all.includes(right))
29
+ return;
30
+ out.add(right);
31
+ // An own field only: a right named `__proto__` or `constructor` reads nothing of the prototype
32
+ const next = Object.hasOwn(this.#model.implies, right) ? this.#model.implies[right] : undefined;
33
+ if (Array.isArray(next))
34
+ for (const implied of next)
35
+ add(implied);
36
+ };
37
+ for (const right of rights)
38
+ add(right);
39
+ return out;
40
+ }
41
+ /** True when an access-list principal names the subject. */
42
+ names(principal, subject) {
43
+ const custom = this.#policy.names?.(principal, subject);
44
+ if (custom !== undefined)
45
+ return custom;
46
+ if (principal === "anyone")
47
+ return true;
48
+ if (!subject.user)
49
+ return false;
50
+ if (principal === "signed-in")
51
+ return true;
52
+ return this.#namesOne(principal, subject);
53
+ }
54
+ /** True when a principal of one user, one email or one group names the subject (the built-in names only). */
55
+ #namesOne(principal, subject) {
56
+ if (!subject.user)
57
+ return false;
58
+ if (principal === `user:${subject.user}`)
59
+ return true;
60
+ if (principal.startsWith("email:"))
61
+ return !!subject.email && normalEmail(principal.slice("email:".length)) === normalEmail(subject.email);
62
+ if (principal.startsWith("group:"))
63
+ return !!subject.groups?.some((group) => principal === `group:${group.id}`);
64
+ return false;
65
+ }
66
+ /** The variables of a `when` condition for a subject. */
67
+ #variables(subject, context) {
68
+ return {
69
+ principal: { user: subject.user ?? null, email: subject.email ?? null, groups: (subject.groups ?? []).map((group) => group.id), signedIn: !!subject.user },
70
+ doc: context.doc ?? null,
71
+ prev: context.prev ?? null,
72
+ op: context.op,
73
+ };
74
+ }
75
+ /** The rights that an access list gives a subject. */
76
+ listRights(list, subject, context) {
77
+ // The owner names one user, one email or one group: never a name of the policy, `anyone` or `signed-in`
78
+ if (list.owner !== undefined && this.#namesOne(list.owner, subject)) {
79
+ return new Set(this.#model.all);
80
+ }
81
+ const rights = [];
82
+ for (const entry of list.access ?? []) {
83
+ if (typeof entry?.principal !== "string" || !this.names(entry.principal, subject))
84
+ continue;
85
+ if (entry.when !== undefined) {
86
+ if (!context || !this.#policy.evaluate)
87
+ continue;
88
+ let passes = false;
89
+ try {
90
+ passes = this.#policy.evaluate(entry.when, this.#variables(subject, context)) === true;
91
+ }
92
+ catch {
93
+ passes = false;
94
+ }
95
+ if (!passes)
96
+ continue;
97
+ }
98
+ for (const right of entry.rights ?? [])
99
+ rights.push(right);
100
+ }
101
+ return this.implied(rights);
102
+ }
103
+ /** The rights of a subject on a scope. */
104
+ rightsOn(scope, subject, lists, context) {
105
+ if ((this.#policy.superuser ?? ((s) => s.agent === true && !s.user))(subject))
106
+ return new Set(this.#model.all);
107
+ const seen = new Set();
108
+ for (let current = scope; current !== undefined && !seen.has(current); current = this.#policy.parent?.(current)) {
109
+ seen.add(current);
110
+ const implicit = this.#policy.implicit?.(current, subject);
111
+ if (implicit !== undefined)
112
+ return this.implied(implicit);
113
+ const list = lists.get(current);
114
+ if (list)
115
+ return this.listRights(list, subject, context);
116
+ if (this.#policy.parent?.(current) === undefined)
117
+ return this.implied(this.#policy.fallback?.(current, subject) ?? []);
118
+ }
119
+ return new Set();
120
+ }
121
+ /** True when the subject has the right on the scope. */
122
+ can(right, scope, subject, lists, context) {
123
+ return this.rightsOn(scope, subject, lists, context).has(right);
124
+ }
125
+ /**
126
+ * True when a conditional entry of the list of the scope (or of the first list of its parents) can
127
+ * give the subject the right on some documents. Then check each document with its context.
128
+ */
129
+ conditionalOn(right, scope, subject, lists) {
130
+ const seen = new Set();
131
+ let list;
132
+ for (let current = scope; current !== undefined && !seen.has(current) && !list; current = this.#policy.parent?.(current)) {
133
+ seen.add(current);
134
+ list = lists.get(current);
135
+ }
136
+ return !!list?.access?.some((entry) => entry?.when !== undefined && typeof entry.principal === "string" && this.names(entry.principal, subject) && this.implied(entry.rights ?? []).has(right));
137
+ }
138
+ /** The scopes of a list that the subject can read. */
139
+ readable(scopes, subject, lists) {
140
+ return scopes.filter((scope) => this.can("read", scope, subject, lists));
141
+ }
142
+ }
143
+ /** This function checks a stored access list: it keeps only the known fields and the entries with a principal and rights. */
144
+ export function asAccessList(raw) {
145
+ if (typeof raw !== "object" || raw === null)
146
+ return undefined;
147
+ const record = raw;
148
+ const access = Array.isArray(record["access"])
149
+ ? record["access"]
150
+ .filter((entry) => typeof entry === "object" && entry !== null && typeof entry["principal"] === "string" && Array.isArray(entry["rights"]))
151
+ .map((entry) => ({
152
+ principal: entry["principal"],
153
+ rights: entry["rights"].filter((right) => typeof right === "string"),
154
+ ...(entry["when"] !== undefined ? { when: entry["when"] } : {}),
155
+ }))
156
+ : undefined;
157
+ return { ...(typeof record["owner"] === "string" ? { owner: record["owner"] } : {}), ...(access ? { access } : {}) };
158
+ }
159
+ //# sourceMappingURL=access.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access.js","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The subject of a principal, and the output filter of a host (`admitItem`) from the access lists.
3
+ */
4
+ import type { AdmitItem, Invocation, Principal } from "@procedures/client";
5
+ import type { Access, AccessLists, AccessSubject, Right } from "./access.js";
6
+ /** The options of `subjectOf`. */
7
+ export interface SubjectOfOptions {
8
+ /** The `via` of a principal without a user (default `anonymous`). */
9
+ readonly anonymousVia?: readonly string[] | undefined;
10
+ /** The `via` of a service account: an agent without a user (default `agent-token` and the host token `token`). */
11
+ readonly serviceVia?: readonly string[] | undefined;
12
+ }
13
+ /**
14
+ * This function gives the subject of a principal of the request hook. The `via` of the principal
15
+ * decides: a service account, anonymous, or a user with the id of the principal. The id never
16
+ * decides, so a user with the id `agent` stays a user. The claims give `email`, `groups` and
17
+ * `agent`. Without a principal (a refused request, or a call from this process), the subject is
18
+ * anonymous.
19
+ */
20
+ export declare function subjectOf(principal: Principal | undefined, options?: SubjectOfOptions): AccessSubject;
21
+ /** The options of `accessAdmitItem`. */
22
+ export interface AccessAdmitOptions {
23
+ readonly access: Access;
24
+ /** The access lists for an invocation, for example from a cache of the documents of the lists. */
25
+ readonly lists: (invocation: Invocation) => AccessLists | Promise<AccessLists>;
26
+ /** The scope of an item. An item without a scope is refused. */
27
+ readonly scopeOf: (item: unknown) => string | undefined;
28
+ /** The subject of the principal of the call (default `subjectOf`). */
29
+ readonly subjectOf?: ((principal: Principal | undefined) => AccessSubject) | undefined;
30
+ /** The right that an item needs (default `read`). */
31
+ readonly right?: Right | undefined;
32
+ }
33
+ /**
34
+ * This function gives the output filter of a host (`admitItem`): it admits an item when the
35
+ * subject has the right on the scope of the item. A conditional entry sees the item as `doc`.
36
+ */
37
+ export declare function accessAdmitItem(options: AccessAdmitOptions): AdmitItem;
38
+ //# sourceMappingURL=admit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admit.d.ts","sourceRoot":"","sources":["../src/admit.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAE7E,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACtD,kHAAkH;IAClH,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CACrD;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,EAAE,OAAO,GAAE,gBAAqB,GAAG,aAAa,CAiBzG;AAED,wCAAwC;AACxC,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kGAAkG;IAClG,QAAQ,CAAC,KAAK,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IACxD,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,KAAK,aAAa,CAAC,GAAG,SAAS,CAAC;IACvF,qDAAqD;IACrD,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CACpC;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,kBAAkB,GAAG,SAAS,CAWtE"}
package/dist/admit.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * This function gives the subject of a principal of the request hook. The `via` of the principal
3
+ * decides: a service account, anonymous, or a user with the id of the principal. The id never
4
+ * decides, so a user with the id `agent` stays a user. The claims give `email`, `groups` and
5
+ * `agent`. Without a principal (a refused request, or a call from this process), the subject is
6
+ * anonymous.
7
+ */
8
+ export function subjectOf(principal, options = {}) {
9
+ if (!principal)
10
+ return {};
11
+ if ((options.serviceVia ?? ["agent-token", "token"]).includes(principal.via))
12
+ return { agent: true };
13
+ if ((options.anonymousVia ?? ["anonymous"]).includes(principal.via))
14
+ return {};
15
+ const claims = principal.claims ?? {};
16
+ const email = typeof claims["email"] === "string" && claims["email"] ? claims["email"] : undefined;
17
+ const groups = Array.isArray(claims["groups"])
18
+ ? claims["groups"]
19
+ .filter((group) => typeof group === "object" && group !== null && "id" in group)
20
+ .map((group) => ({ id: String(group.id), role: typeof group.role === "string" ? group.role : "member" }))
21
+ : undefined;
22
+ return {
23
+ user: principal.id,
24
+ ...(email ? { email } : {}),
25
+ ...(groups ? { groups } : {}),
26
+ ...(claims["agent"] === true ? { agent: true } : {}),
27
+ };
28
+ }
29
+ /**
30
+ * This function gives the output filter of a host (`admitItem`): it admits an item when the
31
+ * subject has the right on the scope of the item. A conditional entry sees the item as `doc`.
32
+ */
33
+ export function accessAdmitItem(options) {
34
+ const right = options.right ?? "read";
35
+ const subjectFor = options.subjectOf ?? ((principal) => subjectOf(principal));
36
+ return async (item, invocation) => {
37
+ const scope = options.scopeOf(item);
38
+ if (scope === undefined)
39
+ return false;
40
+ const subject = subjectFor(invocation.principal);
41
+ const lists = await options.lists(invocation);
42
+ if (options.access.can(right, scope, subject, lists))
43
+ return true;
44
+ return options.access.conditionalOn(right, scope, subject, lists) && options.access.can(right, scope, subject, lists, { op: right, doc: item });
45
+ };
46
+ }
47
+ //# sourceMappingURL=admit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admit.js","sourceRoot":"","sources":["../src/admit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./access.js";
3
+ export * from "./admit.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./access.js";
3
+ export * from "./admit.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Type definitions of the procedures of this package
3
+ *
4
+ * mark procedure new adds the types of each new procedure here.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Type definitions of the procedures of this package
3
+ *
4
+ * mark procedure new adds the types of each new procedure here.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package",
2
3
  "name": "@procedures/access",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
4
+ "version": "0.12.2",
5
+ "description": "The rights of a principal on a scope: access lists, owners, implied rights and conditions over a document, with the rules of the app as a policy, and the output filter of a host.",
6
+ "keywords": [
7
+ "procedures",
8
+ "access-control",
9
+ "authorization",
10
+ "acl",
11
+ "permissions",
12
+ "rights",
13
+ "principal"
14
+ ],
15
+ "homepage": "https://mark1russell7.github.io/client/",
16
+ "bugs": {
17
+ "url": "https://github.com/mark1russell7/client/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/mark1russell7/client.git",
22
+ "directory": "packages/access"
23
+ },
24
+ "license": "MIT",
25
+ "author": "Mark Russell (https://github.com/mark1russell7)",
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "sideEffects": false,
40
+ "devDependencies": {
41
+ "typescript": "^7.0.2",
42
+ "@mark1russell7/cue": "^1.0.0",
43
+ "vitest": "^5.0.3"
44
+ },
45
+ "engines": {
46
+ "node": ">=22"
47
+ },
48
+ "client": null,
49
+ "dependencies": {
50
+ "@procedures/client": "0.12.2",
51
+ "zod": "^4.6.5"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b",
55
+ "typecheck": "tsc --noEmit",
56
+ "clean": "rm -rf dist .tsbuildinfo",
57
+ "test": "vitest run --passWithNoTests",
58
+ "test:watch": "vitest"
59
+ }
6
60
  }
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Invocation } from "@procedures/client";
3
+ import { Access, accessAdmitItem, asAccessList, DEFAULT_RIGHTS, subjectOf, type AccessList, type AccessPolicy, type AccessSubject } from "./index.js";
4
+
5
+ const ALL = DEFAULT_RIGHTS.all;
6
+ const norm = (email: string | undefined) => (email ?? "").trim().toLowerCase();
7
+
8
+ /**
9
+ * The rules of an app with spaces, boards, groups and personal scopes, as a policy: `user:<id>`
10
+ * and `member:<board>:<id>` belong to their person, a group gives rights to its members, a board
11
+ * without a list follows the space, the space owners cannot be locked out, and `link` is anyone.
12
+ */
13
+ function boardsPolicy(spaceOwners: readonly string[], evaluate?: AccessPolicy["evaluate"]): AccessPolicy {
14
+ return {
15
+ evaluate,
16
+ names: (principal) => (principal === "link" ? true : undefined),
17
+ implicit: (scope, subject) => {
18
+ if (scope.startsWith("user:")) return subject.user && scope === `user:${subject.user}` ? ALL : undefined;
19
+ if (scope.startsWith("member:")) return subject.user && scope.slice(scope.lastIndexOf(":") + 1) === subject.user ? ["read", "write"] : undefined;
20
+ if (scope.startsWith("group:")) {
21
+ const group = subject.groups?.find((g) => `group:${g.id}` === scope);
22
+ return group ? (group.role === "owner" || group.role === "admin" ? ALL : ["read", "write"]) : undefined;
23
+ }
24
+ if (scope !== "space" && !scope.startsWith("board:")) return [];
25
+ return subject.user && subject.email && spaceOwners.map(norm).includes(norm(subject.email)) ? ALL : undefined;
26
+ },
27
+ parent: (scope) => (scope.startsWith("board:") ? "space" : undefined),
28
+ fallback: (scope, subject) => (scope === "space" && subject.user && spaceOwners.length === 0 ? ["read", "write"] : []),
29
+ };
30
+ }
31
+
32
+ const lists = (...entries: [string, AccessList][]) => new Map(entries);
33
+ const open = new Access(boardsPolicy([]));
34
+ const owned = new Access(boardsPolicy(["mark@example.com"]));
35
+ const mark: AccessSubject = { user: "u_mark", email: "Mark@Example.com" };
36
+ const ana: AccessSubject = { user: "u_ana", email: "ana@example.com", groups: [{ id: "core", role: "member" }] };
37
+ const anon: AccessSubject = {};
38
+ const sorted = (rights: Set<string>) => [...rights].sort();
39
+
40
+ describe("the rights of a subject on a scope", () => {
41
+ it("anonymous: no rights, except an entry for anyone", () => {
42
+ const pub = lists(["board:pub", { access: [{ principal: "link", rights: ["read"] }] }]);
43
+ expect(sorted(open.rightsOn("space", anon, lists()))).toEqual([]);
44
+ expect(owned.can("read", "board:pub", anon, pub)).toBe(true);
45
+ expect(owned.can("write", "board:pub", anon, pub)).toBe(false);
46
+ expect(new Access().can("read", "x", anon, lists(["x", { access: [{ principal: "anyone", rights: ["read"] }] }]))).toBe(true);
47
+ });
48
+
49
+ it("a scope without a list: the fallback, or the implicit rights of the owners", () => {
50
+ expect(open.can("write", "space", ana, lists())).toBe(true);
51
+ expect(open.can("admin", "space", ana, lists())).toBe(false);
52
+ expect(owned.can("admin", "space", mark, lists())).toBe(true); // the emails compare without case
53
+ expect(owned.can("read", "space", ana, lists())).toBe(false);
54
+ });
55
+
56
+ it("a personal scope belongs to its person, and a scope of an unknown kind gives nothing", () => {
57
+ expect(owned.can("admin", "user:u_ana", ana, lists())).toBe(true);
58
+ expect(owned.can("read", "user:u_ana", mark, lists())).toBe(false);
59
+ expect(owned.can("write", "member:roadmap:u_ana", ana, lists())).toBe(true);
60
+ expect(owned.can("read", "member:roadmap:u_ana", mark, lists())).toBe(false);
61
+ expect(owned.can("read", "other:x", mark, lists(["other:x", { access: [{ principal: "signed-in", rights: ["read"] }] }]))).toBe(false);
62
+ });
63
+
64
+ it("a scope without a list follows its parent, and a list decides with its owner and its entries", () => {
65
+ expect(owned.can("write", "board:b1", mark, lists())).toBe(true);
66
+ expect(owned.can("read", "board:b1", ana, lists())).toBe(false);
67
+ const board = lists(["board:b1", { owner: "user:u_mark", access: [{ principal: "group:core", rights: ["write"] }, { principal: "email:zoe@example.com", rights: ["read"] }] }]);
68
+ expect(sorted(owned.rightsOn("board:b1", mark, board))).toEqual(["admin", "read", "share", "write"]);
69
+ expect(sorted(owned.rightsOn("board:b1", ana, board))).toEqual(["read", "write"]); // write implies read
70
+ expect(owned.can("read", "board:b1", { user: "u_zoe", email: "ZOE@example.com" }, board)).toBe(true);
71
+ expect(owned.can("write", "board:b1", { user: "u_zoe", email: "zoe@example.com" }, board)).toBe(false);
72
+ });
73
+
74
+ it("a group: the members read and write, the owners and the admins have each right", () => {
75
+ expect(sorted(owned.rightsOn("group:core", ana, lists()))).toEqual(["read", "write"]);
76
+ expect(owned.can("admin", "group:core", { user: "u_x", groups: [{ id: "core", role: "owner" }] }, lists())).toBe(true);
77
+ expect(owned.can("read", "group:core", mark, lists())).toBe(false);
78
+ });
79
+
80
+ it("a list cannot lock out a subject with implicit rights, and its owner has each right", () => {
81
+ const locked = lists(["space", { owner: "user:u_ana", access: [] }], ["board:b2", { access: [] }]);
82
+ expect(owned.can("admin", "space", mark, locked)).toBe(true);
83
+ expect(owned.can("admin", "board:b2", mark, locked)).toBe(true);
84
+ expect(owned.can("read", "board:b2", ana, locked)).toBe(false);
85
+ expect(owned.can("admin", "space", ana, locked)).toBe(true);
86
+ });
87
+
88
+ it("an owner names one user, one email or one group: never anyone, signed-in or a name of the policy", () => {
89
+ expect(new Access().can("admin", "x", ana, lists(["x", { owner: "signed-in" }]))).toBe(false);
90
+ expect(new Access().can("admin", "x", ana, lists(["x", { owner: "anyone" }]))).toBe(false);
91
+ expect(owned.can("admin", "board:o", anon, lists(["board:o", { owner: "link" }]))).toBe(false);
92
+ expect(owned.can("admin", "board:o", ana, lists(["board:o", { owner: "link" }]))).toBe(false);
93
+ expect(owned.can("admin", "board:o", ana, lists(["board:o", { owner: "group:core" }]))).toBe(true);
94
+ expect(owned.can("admin", "board:o", ana, lists(["board:o", { owner: "email:ANA@example.com" }]))).toBe(true);
95
+ });
96
+
97
+ it("the agent alone has each right, and an agent that acts for a user has the rights of the user", () => {
98
+ expect(owned.can("admin", "user:u_ana", { agent: true }, lists())).toBe(true);
99
+ expect(owned.can("admin", "space", { agent: true }, lists())).toBe(true);
100
+ const asAna = { ...ana, agent: true };
101
+ expect(owned.can("write", "user:u_ana", asAna, lists())).toBe(true);
102
+ expect(owned.can("read", "user:u_mark", asAna, lists())).toBe(false);
103
+ expect(owned.can("read", "space", asAna, lists())).toBe(false);
104
+ expect(new Access({ superuser: () => false }).can("read", "x", { agent: true }, lists())).toBe(false);
105
+ });
106
+
107
+ it("a when condition gives its rights only where it is exactly true, and an error is false", () => {
108
+ const evaluate = (condition: unknown, vars: Readonly<Record<string, unknown>>) => {
109
+ const c = condition as Record<string, unknown>;
110
+ const doc = vars["doc"] as Record<string, unknown> | null;
111
+ if (c["boom"]) throw new Error("bad");
112
+ if (c["ownerIsMe"]) return doc?.["owner"] === (vars["principal"] as { user: unknown }).user;
113
+ if (c["public"]) return doc?.["public"] === true;
114
+ if (c["truthy"]) return 1 as unknown as boolean;
115
+ return false;
116
+ };
117
+ const access = new Access(boardsPolicy(["mark@example.com"], evaluate));
118
+ const board = lists([
119
+ "board:c1",
120
+ {
121
+ owner: "user:u_mark",
122
+ access: [
123
+ { principal: "email:ana@example.com", rights: ["write"], when: { ownerIsMe: true } },
124
+ { principal: "email:ana@example.com", rights: ["read"], when: { public: true } },
125
+ { principal: "signed-in", rights: ["read"], when: { boom: true } },
126
+ { principal: "signed-in", rights: ["read"], when: { truthy: true } },
127
+ ],
128
+ },
129
+ ]);
130
+ expect(access.can("read", "board:c1", ana, board)).toBe(false); // no document: no conditional right
131
+ expect(access.conditionalOn("read", "board:c1", ana, board)).toBe(true);
132
+ expect(access.conditionalOn("write", "board:c1", ana, board)).toBe(true);
133
+ expect(access.conditionalOn("admin", "board:c1", ana, board)).toBe(false);
134
+ expect(access.can("write", "board:c1", ana, board, { op: "write", doc: { owner: "u_ana" } })).toBe(true);
135
+ expect(access.can("write", "board:c1", ana, board, { op: "write", doc: { owner: "u_mark" } })).toBe(false);
136
+ expect(access.can("read", "board:c1", ana, board, { op: "read", doc: { public: true } })).toBe(true);
137
+ expect(access.can("read", "board:c1", { user: "u_zoe" }, board, { op: "read", doc: {} })).toBe(false); // an error and a value that is not true
138
+ expect(access.can("admin", "board:c1", mark, board)).toBe(true); // the owner is unconditional
139
+ expect(new Access(boardsPolicy([])).can("read", "board:c1", ana, board, { op: "read", doc: { public: true } })).toBe(false); // no evaluator
140
+ expect(owned.conditionalOn("read", "board:none", ana, lists(["space", { access: [{ principal: "signed-in", rights: ["read"], when: {} }] }]))).toBe(true); // from the parent
141
+ });
142
+
143
+ it("gives the readable scopes, and stops at a cycle of parents", () => {
144
+ expect(open.readable(["member:b1:u_ana", "board:b1", "space"], ana, lists())).toEqual(["member:b1:u_ana", "board:b1", "space"]);
145
+ expect(owned.readable(["member:b1:u_ana", "board:b1", "space"], ana, lists())).toEqual(["member:b1:u_ana"]);
146
+ const loop = new Access({ parent: (scope) => (scope === "a" ? "b" : "a"), fallback: () => ["read"] });
147
+ expect(sorted(loop.rightsOn("a", ana, lists()))).toEqual([]);
148
+ expect(loop.conditionalOn("read", "a", ana, lists())).toBe(false);
149
+ });
150
+
151
+ it("follows the rights model of the policy, and ignores an unknown right", () => {
152
+ const model = new Access({ rights: { all: ["view", "edit"], implies: { edit: ["view"] } } });
153
+ expect(sorted(model.implied(["edit", "fly"]))).toEqual(["edit", "view"]);
154
+ expect(sorted(model.rightsOn("x", ana, lists(["x", { owner: "user:u_ana" }])))).toEqual(["edit", "view"]);
155
+ });
156
+
157
+ it("stops at a cycle of implications, and reads only own implications", () => {
158
+ const cycle = new Access({ rights: { all: ["a", "b"], implies: { a: ["b"], b: ["a"] } } });
159
+ expect(sorted(cycle.implied(["a"]))).toEqual(["a", "b"]);
160
+ const odd = new Access({ rights: { all: ["__proto__", "constructor", "toString"], implies: {} } });
161
+ expect(sorted(odd.implied(["__proto__", "constructor", "toString"]))).toEqual(["__proto__", "constructor", "toString"]);
162
+ const hostile = JSON.parse('{"__proto__":{"isAdmin":true},"owner":"user:u_ana","access":[{"principal":"anyone","rights":["read"],"__proto__":{"when":1}}]}');
163
+ const list = asAccessList(hostile)!;
164
+ expect(Object.getPrototypeOf(list)).toBe(Object.prototype);
165
+ expect(list.access?.[0]).toEqual({ principal: "anyone", rights: ["read"] });
166
+ });
167
+
168
+ it("reads a stored access list with care", () => {
169
+ expect(asAccessList({ _id: "board:b", owner: "user:u", access: [{ principal: "link", rights: ["read", 7] }, { bad: 1 }, null], extra: true })).toEqual({
170
+ owner: "user:u",
171
+ access: [{ principal: "link", rights: ["read"] }],
172
+ });
173
+ expect(asAccessList({ access: [{ principal: "x", rights: ["read"], when: { a: 1 } }] })?.access?.[0]?.when).toEqual({ a: 1 });
174
+ expect(asAccessList(null)).toBeUndefined();
175
+ expect(asAccessList({})).toEqual({});
176
+ });
177
+ });
178
+
179
+ describe("the subject of a principal", () => {
180
+ it("reads the user, the email, the groups and the agent mark", () => {
181
+ expect(subjectOf(undefined)).toEqual({});
182
+ expect(subjectOf({ id: "u1", via: "better-auth", claims: { email: "a@b.c", groups: [{ id: "g1", role: "owner" }, { id: 2 }, "bad"] } })).toEqual({
183
+ user: "u1",
184
+ email: "a@b.c",
185
+ groups: [{ id: "g1", role: "owner" }, { id: "2", role: "member" }],
186
+ });
187
+ expect(subjectOf({ id: "u1", via: "agent-as", claims: { agent: true } })).toEqual({ user: "u1", agent: true });
188
+ expect(subjectOf({ id: "agent", via: "agent-token" })).toEqual({ agent: true });
189
+ expect(subjectOf({ id: "token", via: "token" })).toEqual({ agent: true });
190
+ expect(subjectOf({ id: "anonymous", via: "anonymous" })).toEqual({});
191
+ });
192
+
193
+ it("decides by via, never by the id: a user with the id agent, token or anonymous is a user", () => {
194
+ for (const id of ["agent", "token", "anonymous"]) {
195
+ const subject = subjectOf({ id, via: "better-auth" });
196
+ expect(subject).toEqual({ user: id });
197
+ expect(owned.can("admin", "user:someone-else", subject, lists())).toBe(false);
198
+ }
199
+ });
200
+
201
+ it("a refused or unknown act-as is never a service account", () => {
202
+ // A refused act-as gives no principal, and an act-as for an unknown user is anonymous
203
+ for (const principal of [undefined, { id: "anonymous", via: "anonymous", claims: { agent: true, actAs: "ghost" } }]) {
204
+ const subject = subjectOf(principal);
205
+ expect(subject).toEqual({});
206
+ expect(owned.can("read", "space", subject, lists())).toBe(false);
207
+ expect(owned.can("read", "user:u_ana", subject, lists())).toBe(false);
208
+ }
209
+ });
210
+
211
+ it("takes other values of via", () => {
212
+ expect(subjectOf({ id: "g", via: "guest" }, { anonymousVia: ["guest"] })).toEqual({});
213
+ expect(subjectOf({ id: "token", via: "token" }, { serviceVia: [] })).toEqual({ user: "token" });
214
+ });
215
+ });
216
+
217
+ describe("the output filter of a host", () => {
218
+ const invocation = (principal?: { id: string; via: string; claims?: Record<string, unknown> }) => ({ principal }) as unknown as Invocation;
219
+ const board = lists(["board:b1", { access: [{ principal: "email:ana@example.com", rights: ["read"] }, { principal: "signed-in", rights: ["read"], when: { mine: true } }] }]);
220
+ const evaluate = (_c: unknown, vars: Readonly<Record<string, unknown>>) => (vars["doc"] as { by?: unknown } | null)?.by === (vars["principal"] as { user: unknown }).user;
221
+ const admit = accessAdmitItem({
222
+ access: new Access(boardsPolicy(["mark@example.com"], evaluate)),
223
+ lists: async () => board,
224
+ scopeOf: (item) => (item as { _scope?: string })._scope,
225
+ });
226
+
227
+ it("admits an item of a scope that the subject reads, and refuses an item without a scope", async () => {
228
+ expect(await admit({ _scope: "board:b1" }, invocation({ id: "u_ana", via: "better-auth", claims: { email: "ana@example.com" } }))).toBe(true);
229
+ expect(await admit({ _scope: "board:b1" }, invocation({ id: "u_zoe", via: "better-auth" }))).toBe(false);
230
+ expect(await admit({}, invocation({ id: "agent", via: "agent-token" }))).toBe(false);
231
+ expect(await admit({ _scope: "board:b1" }, invocation())).toBe(false);
232
+ });
233
+
234
+ it("checks a conditional entry with the item as the document", async () => {
235
+ expect(await admit({ _scope: "board:b1", by: "u_zoe" }, invocation({ id: "u_zoe", via: "better-auth" }))).toBe(true);
236
+ expect(await admit({ _scope: "board:b1", by: "u_ana" }, invocation({ id: "u_zoe", via: "better-auth" }))).toBe(false);
237
+ });
238
+
239
+ it("takes another right and another subject function", async () => {
240
+ const writes = accessAdmitItem({ access: new Access(), lists: () => lists(["x", { access: [{ principal: "anyone", rights: ["read"] }] }]), scopeOf: () => "x", right: "write", subjectOf: () => ({}) });
241
+ expect(await writes({}, invocation())).toBe(false);
242
+ });
243
+ });