pike/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files draw
389 lines · 13.6 KB · ~4.0k tok Raw History
1
/**
2
 * Who may do what, decided from a table.
3
 *
4
 * An app's permissions are data: `contract.json` declares its roles and, for
5
 * every resource, the least role each of the five verbs needs. This file is
6
 * the whole evaluator. It is self-contained on purpose — no imports, no
7
 * classes, nothing but values and total functions — because it is carried
8
 * verbatim into every app the forge scaffolds, read by the forge to audit an
9
 * app without running it, and meant to be ported line for line.
10
 *
11
 * Roles form an order, not a list. `above` names the roles directly below a
12
 * role, and a role may do whatever anything below it may. How a role is
13
 * *held* is a value too: by everyone, by an identity the forge signed, or by
14
 * the row naming the actor. Adding a role adds a line to the contract and
15
 * nothing here: the evaluator is the same size for three roles or three
16
 * hundred.
17
 *
18
 * Everything fails closed: an undeclared resource, verb or role is "no".
19
 */
20
21
export type Verb = "create" | "read" | "update" | "delete" | "list";
22
export const VERBS: readonly Verb[] = [
23
  "create",
24
  "read",
25
  "update",
26
  "delete",
27
  "list",
28
];
29
30
/** The cell that lets nobody in. Not a role: nothing can be above it. */
31
export const NOBODY = "nobody";
32
33
/** How a role is held. */
34
export type Held =
35
  /** By anyone at all, signed in or not. A contract has exactly one. */
36
  | { readonly by: "everyone" }
37
  /** By an identity the forge signed as carrying `role`. */
38
  | { readonly by: "identity"; readonly role: string }
39
  /** By the row: its `column` holds the actor's id. */
40
  | { readonly by: "row"; readonly column: string };
41
42
export interface Role {
43
  /** The roles directly below this one. */
44
  readonly above: readonly string[];
45
  readonly held: Held;
46
}
47
48
export interface Resource {
49
  /** The least role each verb needs, or `nobody`. */
50
  readonly can: Readonly<Record<Verb, string>>;
51
  /**
52
   * The collection path of this resource's JSON API, when it has one: five
53
   * doors follow by convention (`GET api` lists, `POST api` creates,
54
   * `GET`/`PATCH`/`DELETE api/<id>` read, update and delete one), which is
55
   * how the forge can knock on a running app and compare with the table.
56
   */
57
  readonly api?: string;
58
}
59
60
export interface Contract {
61
  readonly roles: Readonly<Record<string, Role>>;
62
  readonly resources: Readonly<Record<string, Resource>>;
63
}
64
65
/**
66
 * Who is asking: an id when signed in, and the roles the forge signed. An
67
 * empty id is nobody: it never names a row, however a row was filled.
68
 */
69
export interface Actor {
70
  readonly id: string | null;
71
  readonly roles: readonly string[];
72
}
73
74
/**
75
 * The answer. `where` is for a verb with no row in hand (`list`): the actor
76
 * may have exactly the rows whose named columns hold their id.
77
 */
78
export type Decision = "yes" | "no" | { readonly where: readonly string[] };
79
80
/**
81
 * A declared thing by name, and nothing else: `constructor` and its kin are
82
 * on every object and are not roles.
83
 */
84
function own<T>(of: Readonly<Record<string, T>>, name: string): T | undefined {
85
  return Object.prototype.hasOwnProperty.call(of, name) ? of[name] : undefined;
86
}
87
88
/**
89
 * Every role with everything at or below it, computed once per contract
90
 * and kept beside it. A door is asked on every request, and an order of a
91
 * few hundred roles must answer in the time a hash lookup takes, not the
92
 * time a walk takes; the walk happens once, when a contract is first used.
93
 * The cache is keyed by the contract object itself, so it needs no
94
 * invalidation: a new contract is a new object.
95
 */
96
const closures = new WeakMap<Contract, Map<string, Set<string>>>();
97
98
function closureOf(contract: Contract): Map<string, Set<string>> {
99
  const kept = closures.get(contract);
100
  if (kept !== undefined) return kept;
101
  const out = new Map<string, Set<string>>();
102
  for (const name of Object.keys(contract.roles)) {
103
    const seen = new Set<string>([name]);
104
    const walk = [name];
105
    while (walk.length > 0) {
106
      const at = walk.pop()!;
107
      for (const below of own(contract.roles, at)?.above ?? [])
108
        if (!seen.has(below)) {
109
          seen.add(below);
110
          walk.push(below);
111
        }
112
    }
113
    out.set(name, seen);
114
  }
115
  closures.set(contract, out);
116
  return out;
117
}
118
119
/** Whether role `low` is `high` or below it; an undeclared role is neither. */
120
export function atOrBelow(
121
  contract: Contract,
122
  low: string,
123
  high: string,
124
): boolean {
125
  return closureOf(contract).get(high)?.has(low) ?? false;
126
}
127
128
export function decide(
129
  contract: Contract,
130
  resource: string,
131
  verb: Verb,
132
  actor: Actor,
133
  row?: Readonly<Record<string, unknown>>,
134
): Decision {
135
  const can = own(contract.resources, resource)?.can;
136
  const need = can === undefined ? undefined : own(can, verb);
137
  if (need === undefined || own(contract.roles, need) === undefined)
138
    return "no";
139
  const where: string[] = [];
140
  for (const name of Object.keys(contract.roles)) {
141
    if (!atOrBelow(contract, need, name)) continue;
142
    const held = contract.roles[name]!.held;
143
    if (held.by === "everyone") return "yes";
144
    if (held.by === "identity") {
145
      if (actor.roles.includes(held.role)) return "yes";
146
      continue;
147
    }
148
    if (actor.id === null || actor.id === "") continue;
149
    if (row !== undefined) {
150
      if (own(row, held.column) === actor.id) return "yes";
151
    } else if (verb === "list" && !where.includes(held.column))
152
      where.push(held.column);
153
  }
154
  return where.length > 0 ? { where } : "no";
155
}
156
157
/** `decide`, for the doors that only need yes or no about one row. */
158
export const may = (
159
  contract: Contract,
160
  resource: string,
161
  verb: Verb,
162
  actor: Actor,
163
  row?: Readonly<Record<string, unknown>>,
164
): boolean => decide(contract, resource, verb, actor, row) === "yes";
165
166
// ---- reading a contract: strict, total, every problem named ----
167
168
export type Read =
169
  | { readonly ok: true; readonly contract: Contract }
170
  | { readonly ok: false; readonly problems: readonly string[] };
171
172
const isObject = (v: unknown): v is Record<string, unknown> =>
173
  typeof v === "object" && v !== null && !Array.isArray(v);
174
const NAME = /^[a-z][a-z0-9_]{0,39}$/;
175
/** An absolute path of plain segments: no query, no dot-segments, no host. */
176
const API_PATH = /^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/;
177
178
function readHeld(v: unknown): Held | undefined {
179
  if (!isObject(v)) return undefined;
180
  if (v["by"] === "everyone") return { by: "everyone" };
181
  if (v["by"] === "identity" && typeof v["role"] === "string")
182
    return NAME.test(v["role"])
183
      ? { by: "identity", role: v["role"] }
184
      : undefined;
185
  if (v["by"] === "row" && typeof v["column"] === "string")
186
    return NAME.test(v["column"])
187
      ? { by: "row", column: v["column"] }
188
      : undefined;
189
  return undefined;
190
}
191
192
/** Parse and check a contract from untrusted JSON text. */
193
export function readContract(text: string): Read {
194
  let raw: unknown;
195
  try {
196
    raw = JSON.parse(text);
197
  } catch {
198
    return { ok: false, problems: ["contract.json is not JSON"] };
199
  }
200
  if (!isObject(raw) || !isObject(raw["roles"]) || !isObject(raw["resources"]))
201
    return {
202
      ok: false,
203
      problems: ["a contract is an object with `roles` and `resources`"],
204
    };
205
  const problems: string[] = [];
206
  const roles: Record<string, Role> = {};
207
  for (const [name, v] of Object.entries(raw["roles"])) {
208
    const held = isObject(v) ? readHeld(v["held"]) : undefined;
209
    const above = isObject(v) ? (v["above"] ?? []) : undefined;
210
    if (!NAME.test(name) || name === NOBODY)
211
      problems.push(`role \`${name}\`: not a usable name`);
212
    else if (
213
      held === undefined ||
214
      !Array.isArray(above) ||
215
      !above.every((a): a is string => typeof a === "string")
216
    )
217
      problems.push(`role \`${name}\`: needs \`held\` and a list \`above\``);
218
    else roles[name] = { above, held };
219
  }
220
  const resources: Record<string, Resource> = {};
221
  for (const [name, v] of Object.entries(raw["resources"])) {
222
    const can = isObject(v) && isObject(v["can"]) ? v["can"] : undefined;
223
    if (!NAME.test(name) || !isObject(v) || can === undefined) {
224
      problems.push(`resource \`${name}\`: needs a name and a \`can\` table`);
225
      continue;
226
    }
227
    const cells = {} as Record<Verb, string>;
228
    for (const verb of VERBS) {
229
      const cell = can[verb];
230
      if (typeof cell === "string") cells[verb] = cell;
231
      else
232
        problems.push(`${name}.${verb}: every verb names a role or \`nobody\``);
233
    }
234
    for (const extra of Object.keys(can))
235
      if (!VERBS.includes(extra as Verb))
236
        problems.push(`${name}.${extra}: not one of ${VERBS.join(", ")}`);
237
    const api = v["api"];
238
    if (api !== undefined && (typeof api !== "string" || !API_PATH.test(api)))
239
      problems.push(
240
        `resource \`${name}\`: \`api\` is an absolute path of plain segments, like /api/${name}s`,
241
      );
242
    resources[name] =
243
      typeof api === "string" && API_PATH.test(api)
244
        ? { can: cells, api }
245
        : { can: cells };
246
  }
247
  const contract: Contract = { roles, resources };
248
  problems.push(...check(contract));
249
  // A cell read here and checked there is one problem, said once.
250
  const unique = [...new Set(problems)];
251
  return unique.length > 0
252
    ? { ok: false, problems: unique }
253
    : { ok: true, contract };
254
}
255
256
/** What is wrong with a contract that parsed. Empty means sound. */
257
export function check(contract: Contract): string[] {
258
  const problems: string[] = [];
259
  const names = Object.keys(contract.roles);
260
  const floors = names.filter((n) => contract.roles[n]!.held.by === "everyone");
261
  if (floors.length !== 1)
262
    problems.push("exactly one role is held by everyone: the floor");
263
  // Two roles held by one identity are one role with two names: the table
264
  // would show two columns that are the same person.
265
  const byIdentity = new Map<string, string>();
266
  for (const name of names) {
267
    const held = contract.roles[name]!.held;
268
    if (held.by !== "identity") continue;
269
    const other = byIdentity.get(held.role);
270
    if (other !== undefined)
271
      problems.push(
272
        `roles \`${other}\` and \`${name}\` are both held by the identity \`${held.role}\``,
273
      );
274
    else byIdentity.set(held.role, name);
275
  }
276
  for (const name of names) {
277
    for (const below of contract.roles[name]!.above) {
278
      if (own(contract.roles, below) === undefined)
279
        problems.push(
280
          `role \`${name}\` is above \`${below}\`, which is not declared`,
281
        );
282
      else if (atOrBelow(contract, name, below))
283
        problems.push(`role \`${name}\` is both above and below \`${below}\``);
284
    }
285
    const floor = floors[0];
286
    if (floor !== undefined && !atOrBelow(contract, floor, name))
287
      problems.push(
288
        `role \`${name}\` does not stand on the floor \`${floor}\``,
289
      );
290
  }
291
  for (const [resource, { can }] of Object.entries(contract.resources))
292
    for (const verb of VERBS) {
293
      const need = own(can, verb);
294
      if (need === undefined) {
295
        problems.push(
296
          `${resource}.${verb}: every verb names a role or \`nobody\``,
297
        );
298
        continue;
299
      }
300
      if (need === NOBODY) continue;
301
      const role = own(contract.roles, need);
302
      if (role === undefined)
303
        problems.push(
304
          `${resource}.${verb} needs \`${need}\`, which is not declared`,
305
        );
306
      else if (verb === "create" && role.held.by === "row")
307
        problems.push(
308
          `${resource}.create needs \`${need}\`, which a row holds: there is no row yet`,
309
        );
310
    }
311
  return problems;
312
}
313
314
// ---- the table: the contract as the audit reads it ----
315
316
export interface Cell {
317
  readonly resource: string;
318
  readonly verb: Verb;
319
  /** Who is asking: the floor, or a role an identity carries. */
320
  readonly as: string;
321
  /** `own`: only the rows that name the actor. */
322
  readonly answer: "yes" | "no" | "own";
323
}
324
325
/**
326
 * Who someone can be. A role a row holds is not one of them: it is what an
327
 * actor is *to a row*, and shows in the table as `own`.
328
 */
329
export const identities = (contract: Contract): string[] =>
330
  Object.keys(contract.roles).filter(
331
    (name) => contract.roles[name]!.held.by !== "row",
332
  );
333
334
/**
335
 * The facts that make someone `identity` and nothing more: the forge acts as
336
 * each identity this way, for the table here and for the audit's probes.
337
 * `mine` is a row that names the actor wherever a row can; `theirs` names
338
 * someone else. Anything that is not an identity acts as a stranger.
339
 */
340
export function actingAs(
341
  contract: Contract,
342
  identity: string,
343
): {
344
  actor: Actor;
345
  mine: Record<string, unknown>;
346
  theirs: Record<string, unknown>;
347
} {
348
  const held = own(contract.roles, identity)?.held;
349
  const actor: Actor =
350
    held?.by === "identity"
351
      ? { id: "the-actor", roles: [held.role] }
352
      : { id: null, roles: [] };
353
  const mine: Record<string, unknown> = {};
354
  const theirs: Record<string, unknown> = {};
355
  for (const role of Object.values(contract.roles))
356
    if (role.held.by === "row") {
357
      mine[role.held.column] = "the-actor";
358
      theirs[role.held.column] = "someone-else";
359
    }
360
  return { actor, mine, theirs };
361
}
362
363
/** Every resource, verb and identity, answered. The policy, as a table. */
364
export function table(contract: Contract): Cell[] {
365
  const out: Cell[] = [];
366
  for (const resource of Object.keys(contract.resources))
367
    for (const verb of VERBS)
368
      for (const as of identities(contract)) {
369
        const { actor, mine, theirs } = actingAs(contract, as);
370
        const rowless = verb === "create" || verb === "list";
371
        const any = decide(
372
          contract,
373
          resource,
374
          verb,
375
          actor,
376
          rowless ? undefined : theirs,
377
        );
378
        const owned = rowless
379
          ? any
380
          : decide(contract, resource, verb, actor, mine);
381
        out.push({
382
          resource,
383
          verb,
384
          as,
385
          answer: any === "yes" ? "yes" : owned === "no" ? "no" : "own",
386
        });
387
      }
388
  return out;
389
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts