@procedures/access 0.0.0-bootstrap.0 → 0.12.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/src/access.ts ADDED
@@ -0,0 +1,226 @@
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
+
11
+ /** A right, for example `read`. The rights model of the policy gives the set and the implications. */
12
+ export type Right = string;
13
+
14
+ /** The rights of an app, and the rights that each right implies. */
15
+ export interface RightsModel {
16
+ readonly all: readonly Right[];
17
+ readonly implies: Readonly<Record<Right, readonly Right[]>>;
18
+ }
19
+
20
+ /** The default model: `admin` implies `share` and `write`, and each of them implies `read`. */
21
+ export const DEFAULT_RIGHTS: RightsModel = Object.freeze({
22
+ all: Object.freeze(["read", "write", "share", "admin"]),
23
+ implies: Object.freeze({ admin: Object.freeze(["share", "write"]), share: Object.freeze(["read"]), write: Object.freeze(["read"]) }),
24
+ });
25
+
26
+ /** Who asks: the user, the verified email, the groups (with the role in each) and the agent mark. */
27
+ export interface AccessSubject {
28
+ /** The id of the signed-in user. Absent: anonymous. */
29
+ readonly user?: string | undefined;
30
+ /** The verified email. */
31
+ readonly email?: string | undefined;
32
+ readonly groups?: readonly { readonly id: string; readonly role: string }[] | undefined;
33
+ /** A program (an agent). Without `user`, it is a service account. With `user`, it acts for that user. */
34
+ readonly agent?: boolean | undefined;
35
+ }
36
+
37
+ /**
38
+ * An entry of an access list. `principal` names who gets the rights: `user:<id>`, `group:<id>`,
39
+ * `email:<address>`, `signed-in` (each signed-in user), `anyone`, or a name of the policy.
40
+ */
41
+ export interface AccessEntry {
42
+ readonly principal: string;
43
+ readonly rights: readonly Right[];
44
+ /** A condition: the rights apply only where the policy evaluates it to exactly `true`. */
45
+ readonly when?: unknown;
46
+ }
47
+
48
+ /** The access list of a scope: an owner (each right: `user:`, `email:` or `group:`) and entries. */
49
+ export interface AccessList {
50
+ readonly owner?: string | undefined;
51
+ readonly access?: readonly AccessEntry[] | undefined;
52
+ }
53
+
54
+ /** The operation and the document of a check, for the `when` conditions. */
55
+ export interface AccessContext {
56
+ /** For example `read`, `write` or `delete`. */
57
+ readonly op: string;
58
+ /** The document that the operation reads or writes. */
59
+ readonly doc?: unknown;
60
+ /** The stored document before a write or a delete. */
61
+ readonly prev?: unknown;
62
+ }
63
+
64
+ /** The rules of an app. */
65
+ export interface AccessPolicy {
66
+ /** The rights model (default `DEFAULT_RIGHTS`). */
67
+ readonly rights?: RightsModel | undefined;
68
+ /**
69
+ * The evaluator of a `when` condition. It gets the condition and the variables `principal`, `doc`,
70
+ * `prev` and `op`. Only `true` counts, and an error is false. Without it, a conditional entry gives nothing.
71
+ */
72
+ readonly evaluate?: ((condition: unknown, variables: Readonly<Record<string, unknown>>) => boolean) | undefined;
73
+ /**
74
+ * The rights that hold before the access list, for example each right of the owner of a personal
75
+ * scope. A defined result is final: the list, the parent and the fallback do not apply.
76
+ */
77
+ readonly implicit?: ((scope: string, subject: AccessSubject) => Iterable<Right> | undefined) | undefined;
78
+ /** The scope whose rights a scope without an access list takes, for example the space for a board. */
79
+ readonly parent?: ((scope: string) => string | undefined) | undefined;
80
+ /** The rights of a scope without an access list and without a parent (default: none). */
81
+ readonly fallback?: ((scope: string, subject: AccessSubject) => Iterable<Right> | undefined) | undefined;
82
+ /** More principal names of the app. It gives undefined to use the default names. */
83
+ readonly names?: ((principal: string, subject: AccessSubject) => boolean | undefined) | undefined;
84
+ /** A subject with each right in each scope (default: an agent without a user). */
85
+ readonly superuser?: ((subject: AccessSubject) => boolean) | undefined;
86
+ }
87
+
88
+ /** The access lists of the scopes, by scope. */
89
+ export type AccessLists = ReadonlyMap<string, AccessList>;
90
+
91
+ const normalEmail = (email: string | undefined): string => (email ?? "").trim().toLowerCase();
92
+
93
+ /** The engine of the rights of a policy. */
94
+ export class Access {
95
+ readonly #policy: AccessPolicy;
96
+ readonly #model: RightsModel;
97
+
98
+ constructor(policy: AccessPolicy = {}) {
99
+ this.#policy = policy;
100
+ this.#model = policy.rights ?? DEFAULT_RIGHTS;
101
+ }
102
+
103
+ /** This method gives the rights and each right that they imply. It ignores a right that the model does not know. */
104
+ implied(rights: Iterable<Right>): Set<Right> {
105
+ const out = new Set<Right>();
106
+ const add = (right: Right): void => {
107
+ if (out.has(right) || !this.#model.all.includes(right)) return;
108
+ out.add(right);
109
+ // An own field only: a right named `__proto__` or `constructor` reads nothing of the prototype
110
+ const next = Object.hasOwn(this.#model.implies, right) ? this.#model.implies[right] : undefined;
111
+ if (Array.isArray(next)) for (const implied of next) add(implied);
112
+ };
113
+ for (const right of rights) add(right);
114
+ return out;
115
+ }
116
+
117
+ /** True when an access-list principal names the subject. */
118
+ names(principal: string, subject: AccessSubject): boolean {
119
+ const custom = this.#policy.names?.(principal, subject);
120
+ if (custom !== undefined) return custom;
121
+ if (principal === "anyone") return true;
122
+ if (!subject.user) return false;
123
+ if (principal === "signed-in") return true;
124
+ return this.#namesOne(principal, subject);
125
+ }
126
+
127
+ /** True when a principal of one user, one email or one group names the subject (the built-in names only). */
128
+ #namesOne(principal: string, subject: AccessSubject): boolean {
129
+ if (!subject.user) return false;
130
+ if (principal === `user:${subject.user}`) return true;
131
+ if (principal.startsWith("email:")) return !!subject.email && normalEmail(principal.slice("email:".length)) === normalEmail(subject.email);
132
+ if (principal.startsWith("group:")) return !!subject.groups?.some((group) => principal === `group:${group.id}`);
133
+ return false;
134
+ }
135
+
136
+ /** The variables of a `when` condition for a subject. */
137
+ #variables(subject: AccessSubject, context: AccessContext): Record<string, unknown> {
138
+ return {
139
+ principal: { user: subject.user ?? null, email: subject.email ?? null, groups: (subject.groups ?? []).map((group) => group.id), signedIn: !!subject.user },
140
+ doc: context.doc ?? null,
141
+ prev: context.prev ?? null,
142
+ op: context.op,
143
+ };
144
+ }
145
+
146
+ /** The rights that an access list gives a subject. */
147
+ listRights(list: AccessList, subject: AccessSubject, context?: AccessContext): Set<Right> {
148
+ // The owner names one user, one email or one group: never a name of the policy, `anyone` or `signed-in`
149
+ if (list.owner !== undefined && this.#namesOne(list.owner, subject)) {
150
+ return new Set(this.#model.all);
151
+ }
152
+ const rights: Right[] = [];
153
+ for (const entry of list.access ?? []) {
154
+ if (typeof entry?.principal !== "string" || !this.names(entry.principal, subject)) continue;
155
+ if (entry.when !== undefined) {
156
+ if (!context || !this.#policy.evaluate) continue;
157
+ let passes = false;
158
+ try {
159
+ passes = this.#policy.evaluate(entry.when, this.#variables(subject, context)) === true;
160
+ } catch {
161
+ passes = false;
162
+ }
163
+ if (!passes) continue;
164
+ }
165
+ for (const right of entry.rights ?? []) rights.push(right);
166
+ }
167
+ return this.implied(rights);
168
+ }
169
+
170
+ /** The rights of a subject on a scope. */
171
+ rightsOn(scope: string, subject: AccessSubject, lists: AccessLists, context?: AccessContext): Set<Right> {
172
+ if ((this.#policy.superuser ?? ((s: AccessSubject) => s.agent === true && !s.user))(subject)) return new Set(this.#model.all);
173
+ const seen = new Set<string>();
174
+ for (let current: string | undefined = scope; current !== undefined && !seen.has(current); current = this.#policy.parent?.(current)) {
175
+ seen.add(current);
176
+ const implicit = this.#policy.implicit?.(current, subject);
177
+ if (implicit !== undefined) return this.implied(implicit);
178
+ const list = lists.get(current);
179
+ if (list) return this.listRights(list, subject, context);
180
+ if (this.#policy.parent?.(current) === undefined) return this.implied(this.#policy.fallback?.(current, subject) ?? []);
181
+ }
182
+ return new Set();
183
+ }
184
+
185
+ /** True when the subject has the right on the scope. */
186
+ can(right: Right, scope: string, subject: AccessSubject, lists: AccessLists, context?: AccessContext): boolean {
187
+ return this.rightsOn(scope, subject, lists, context).has(right);
188
+ }
189
+
190
+ /**
191
+ * True when a conditional entry of the list of the scope (or of the first list of its parents) can
192
+ * give the subject the right on some documents. Then check each document with its context.
193
+ */
194
+ conditionalOn(right: Right, scope: string, subject: AccessSubject, lists: AccessLists): boolean {
195
+ const seen = new Set<string>();
196
+ let list: AccessList | undefined;
197
+ for (let current: string | undefined = scope; current !== undefined && !seen.has(current) && !list; current = this.#policy.parent?.(current)) {
198
+ seen.add(current);
199
+ list = lists.get(current);
200
+ }
201
+ return !!list?.access?.some(
202
+ (entry) => entry?.when !== undefined && typeof entry.principal === "string" && this.names(entry.principal, subject) && this.implied(entry.rights ?? []).has(right)
203
+ );
204
+ }
205
+
206
+ /** The scopes of a list that the subject can read. */
207
+ readable(scopes: readonly string[], subject: AccessSubject, lists: AccessLists): string[] {
208
+ return scopes.filter((scope) => this.can("read", scope, subject, lists));
209
+ }
210
+ }
211
+
212
+ /** This function checks a stored access list: it keeps only the known fields and the entries with a principal and rights. */
213
+ export function asAccessList(raw: unknown): AccessList | undefined {
214
+ if (typeof raw !== "object" || raw === null) return undefined;
215
+ const record = raw as Record<string, unknown>;
216
+ const access = Array.isArray(record["access"])
217
+ ? (record["access"] as unknown[])
218
+ .filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null && typeof (entry as Record<string, unknown>)["principal"] === "string" && Array.isArray((entry as Record<string, unknown>)["rights"]))
219
+ .map((entry) => ({
220
+ principal: entry["principal"] as string,
221
+ rights: (entry["rights"] as unknown[]).filter((right): right is string => typeof right === "string"),
222
+ ...(entry["when"] !== undefined ? { when: entry["when"] } : {}),
223
+ }))
224
+ : undefined;
225
+ return { ...(typeof record["owner"] === "string" ? { owner: record["owner"] } : {}), ...(access ? { access } : {}) };
226
+ }
package/src/admit.ts ADDED
@@ -0,0 +1,69 @@
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
+
7
+ /** The options of `subjectOf`. */
8
+ export interface SubjectOfOptions {
9
+ /** The `via` of a principal without a user (default `anonymous`). */
10
+ readonly anonymousVia?: readonly string[] | undefined;
11
+ /** The `via` of a service account: an agent without a user (default `agent-token` and the host token `token`). */
12
+ readonly serviceVia?: readonly string[] | undefined;
13
+ }
14
+
15
+ /**
16
+ * This function gives the subject of a principal of the request hook. The `via` of the principal
17
+ * decides: a service account, anonymous, or a user with the id of the principal. The id never
18
+ * decides, so a user with the id `agent` stays a user. The claims give `email`, `groups` and
19
+ * `agent`. Without a principal (a refused request, or a call from this process), the subject is
20
+ * anonymous.
21
+ */
22
+ export function subjectOf(principal: Principal | undefined, options: SubjectOfOptions = {}): AccessSubject {
23
+ if (!principal) return {};
24
+ if ((options.serviceVia ?? ["agent-token", "token"]).includes(principal.via)) return { agent: true };
25
+ if ((options.anonymousVia ?? ["anonymous"]).includes(principal.via)) return {};
26
+ const claims = principal.claims ?? {};
27
+ const email = typeof claims["email"] === "string" && claims["email"] ? claims["email"] : undefined;
28
+ const groups = Array.isArray(claims["groups"])
29
+ ? (claims["groups"] as unknown[])
30
+ .filter((group): group is { id: unknown; role?: unknown } => typeof group === "object" && group !== null && "id" in group)
31
+ .map((group) => ({ id: String(group.id), role: typeof group.role === "string" ? group.role : "member" }))
32
+ : undefined;
33
+ return {
34
+ user: principal.id,
35
+ ...(email ? { email } : {}),
36
+ ...(groups ? { groups } : {}),
37
+ ...(claims["agent"] === true ? { agent: true } : {}),
38
+ };
39
+ }
40
+
41
+ /** The options of `accessAdmitItem`. */
42
+ export interface AccessAdmitOptions {
43
+ readonly access: Access;
44
+ /** The access lists for an invocation, for example from a cache of the documents of the lists. */
45
+ readonly lists: (invocation: Invocation) => AccessLists | Promise<AccessLists>;
46
+ /** The scope of an item. An item without a scope is refused. */
47
+ readonly scopeOf: (item: unknown) => string | undefined;
48
+ /** The subject of the principal of the call (default `subjectOf`). */
49
+ readonly subjectOf?: ((principal: Principal | undefined) => AccessSubject) | undefined;
50
+ /** The right that an item needs (default `read`). */
51
+ readonly right?: Right | undefined;
52
+ }
53
+
54
+ /**
55
+ * This function gives the output filter of a host (`admitItem`): it admits an item when the
56
+ * subject has the right on the scope of the item. A conditional entry sees the item as `doc`.
57
+ */
58
+ export function accessAdmitItem(options: AccessAdmitOptions): AdmitItem {
59
+ const right = options.right ?? "read";
60
+ const subjectFor = options.subjectOf ?? ((principal: Principal | undefined) => subjectOf(principal));
61
+ return async (item, invocation) => {
62
+ const scope = options.scopeOf(item);
63
+ if (scope === undefined) return false;
64
+ const subject = subjectFor(invocation.principal);
65
+ const lists = await options.lists(invocation);
66
+ if (options.access.can(right, scope, subject, lists)) return true;
67
+ return options.access.conditionalOn(right, scope, subject, lists) && options.access.can(right, scope, subject, lists, { op: right, doc: item });
68
+ };
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export * from "./access.js";
3
+ export * from "./admit.js";
package/src/types.ts 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
+
7
+ export {};