@celsoft/platform 0.16.0 → 0.17.0

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.
@@ -2,13 +2,15 @@ import { sql } from "drizzle-orm";
2
2
  import { EndpointError } from "./endpoint-error.js";
3
3
  import { LINK_HEADER } from "./link-header.js";
4
4
  export { LINK_HEADER };
5
- // Who may do what to an app's items; see docs/design.md, App Code, Access, and the cases in
6
- // docs/access-rules.md. Every check is one query against the app's own Postgres, never cached.
5
+ // Who may do what to an app's resources, through control points; see docs/design.md, App Code,
6
+ // Access, and the cases in docs/access-rules.md. Every check is one query against the app's own
7
+ // Postgres, never cached.
7
8
  export const LEVELS = ["read", "create", "write", "delete", "manage"];
8
- // The longest chain from an item up to its user or organization, counting the item itself.
9
+ // The longest chain from a point up through its parents, counting the point itself.
9
10
  export const MAX_DEPTH = 64;
10
- const ROOTS = ["user", "organization"];
11
- const KIND = /^[a-z][a-z0-9_-]{0,62}$/;
11
+ export const organizationPoint = (organizationId) => `organization:${organizationId}`;
12
+ export const teamPoint = (teamId) => `team:${teamId}`;
13
+ const RESERVED = /^(organization|team):/;
12
14
  const [READ, CREATE, WRITE, DELETE, MANAGE] = [1, 2, 3, 4, 5];
13
15
  export function rank(level) {
14
16
  const n = LEVELS.indexOf(level);
@@ -17,16 +19,12 @@ export function rank(level) {
17
19
  return n + 1;
18
20
  }
19
21
  const named = (n) => (n >= READ ? LEVELS[n - 1] : null);
20
- function checkKind(kind) {
21
- if (typeof kind !== "string" || !KIND.test(kind) || ROOTS.includes(kind)) {
22
- throw new Error(`access kind ${JSON.stringify(kind)} must be lowercase letters, digits, - and _, and not "user" or "organization"`);
23
- }
24
- }
25
- function checkId(id) {
22
+ export function checkPointId(id) {
26
23
  if (typeof id !== "string" || id.length === 0 || id.length > 200)
27
- throw new Error(`access id ${JSON.stringify(id)} must be 1 to 200 characters`);
24
+ throw new Error(`a control point id must be 1 to 200 characters; got ${JSON.stringify(id)}`);
28
25
  }
29
26
  const notFound = () => new EndpointError(404, "not found");
27
+ const conflict = (message) => new EndpointError(409, message);
30
28
  export function shareMessage(notice, appName, origin) {
31
29
  const who = notice.sharedBy || "Someone";
32
30
  const url = new URL(notice.path, origin).toString();
@@ -44,57 +42,74 @@ function newToken() {
44
42
  const bytes = crypto.getRandomValues(new Uint8Array(32));
45
43
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
46
44
  }
47
- // The caller's level on one item, 0 for none, as a scalar subquery. It walks up the chain to the
48
- // user or organization at the top, stops counting grants and links above the first restricted
49
- // item, and gives nothing at all to a chain that never reaches a user or organization that exists.
50
- function levelSql(kind, id, who) {
45
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
+ // A user id that is not a UUID can match nobody, so it is treated as no user at all.
47
+ const userParam = (userId) => (userId && UUID.test(userId) ? userId : null);
48
+ // The caller's level on one point, 0 for none, as a scalar subquery. The chain is the point and,
49
+ // while each point inherits, its parents. Each of the caller's principals (the person, by user id
50
+ // or confirmed email, each team, each organization, and Everyone) stops at its nearest grant on
51
+ // the chain, and the highest of those wins; organization and team grants count only on points of
52
+ // their own organization or none. The organization's Admins get `manage`, and a link counts only
53
+ // on the point it was made for.
54
+ function levelSql(point, who) {
55
+ if (who.app)
56
+ return sql `(select case when exists (select 1 from access_point where id = (${point})) then ${MANAGE} else 0 end)`;
57
+ const userId = userParam(who.userId);
58
+ // The point is read once, in a query with no tables of its own, so a column the app passes,
59
+ // such as `p.id`, always means the app's row; every name inside starts with `_ac` for the same reason.
51
60
  return sql `(
52
- with recursive chain as (
53
- select i.kind, i.id, i.parent_kind, i.parent_id, i.restricted, 0 as depth
54
- from access_item i where i.kind = ${kind} and i.id = ${id}
61
+ with recursive _ac_target as (select (${point}) as id),
62
+ _ac_chain as (
63
+ select _ac_p.id, _ac_p.parent_id, _ac_p.inherits, _ac_p.organization_id, 0 as depth
64
+ from access_point _ac_p where _ac_p.id = (select id from _ac_target)
55
65
  union all
56
- select p.kind, p.id, p.parent_kind, p.parent_id, p.restricted, c.depth + 1
57
- from chain c join access_item p on p.kind = c.parent_kind and p.id = c.parent_id
58
- where c.depth < ${MAX_DEPTH - 1}
66
+ select _ac_q.id, _ac_q.parent_id, _ac_q.inherits, _ac_q.organization_id, _ac_c.depth + 1
67
+ from _ac_chain _ac_c join access_point _ac_q on _ac_q.id = _ac_c.parent_id
68
+ where _ac_c.inherits and _ac_c.depth < ${MAX_DEPTH - 1}
59
69
  ),
60
- root as (
61
- select parent_kind as kind, parent_id as id from chain
62
- where (parent_kind = 'user' and exists (select 1 from "user" u where u.id::text = parent_id))
63
- or (parent_kind = 'organization' and exists (select 1 from organization o where o.id::text = parent_id))
70
+ _ac_me as (select _ac_u.id, case when _ac_u.email_verified then lower(_ac_u.email) end as email from "user" _ac_u where _ac_u.id = ${userId}::uuid),
71
+ _ac_orgs as (select _ac_m.organization_id from member _ac_m join _ac_me on _ac_m.user_id = _ac_me.id),
72
+ _ac_teams as (
73
+ select _ac_tm.team_id, _ac_t.admins, _ac_t.organization_id from team_member _ac_tm
74
+ join _ac_me on _ac_tm.user_id = _ac_me.id
75
+ join team _ac_t on _ac_t.id = _ac_tm.team_id
76
+ join _ac_orgs on _ac_orgs.organization_id = _ac_t.organization_id
64
77
  ),
65
- cut as (select min(depth) as depth from chain where restricted),
66
- reach as (select chain.kind, chain.id from chain, cut where cut.depth is null or chain.depth <= cut.depth),
67
- me as (select ${who.userId}::uuid as id),
68
- levels as (
69
- select ${MANAGE} as level from root, me where root.kind = 'user' and root.id = me.id::text
70
- union all
71
- select case btrim(roles.name)
72
- when 'owner' then ${MANAGE}
73
- when 'admin' then ${DELETE}
74
- when 'member' then (select case when cut.depth is null then ${READ} else 0 end from cut)
75
- else 0 end
76
- from root
77
- join member m on root.kind = 'organization' and m.organization_id::text = root.id
78
- join me on m.user_id = me.id
79
- cross join lateral unnest(string_to_array(m.role, ',')) as roles(name)
78
+ _ac_matched as (
79
+ select _ac_c.depth, _ac_g.level,
80
+ case when _ac_g.user_id is not null or _ac_g.email is not null then 'person'
81
+ when _ac_g.team_id is not null then 'team ' || _ac_g.team_id
82
+ when _ac_g.organization_id is not null then 'organization ' || _ac_g.organization_id
83
+ else 'everyone' end as principal
84
+ from _ac_chain _ac_c join access_grant _ac_g on _ac_g.point_id = _ac_c.id
85
+ where _ac_g.everyone
86
+ or _ac_g.user_id = (select id from _ac_me)
87
+ or _ac_g.email = (select email from _ac_me)
88
+ or (_ac_g.organization_id in (select organization_id from _ac_orgs) and (_ac_c.organization_id is null or _ac_c.organization_id = _ac_g.organization_id))
89
+ or exists (select 1 from _ac_teams _ac_x where _ac_x.team_id = _ac_g.team_id and (_ac_c.organization_id is null or _ac_c.organization_id = _ac_x.organization_id))
90
+ ),
91
+ _ac_nearest as (select distinct on (principal) level from _ac_matched order by principal, depth, level desc),
92
+ _ac_levels as (
93
+ select level from _ac_nearest
80
94
  union all
81
- select g.level from access_grant g join reach on g.item_kind = reach.kind and g.item_id = reach.id, me
82
- where g.user_id = me.id
83
- or g.team_id in (select tm.team_id from team_member tm where tm.user_id = me.id)
84
- or g.organization_id in (select mm.organization_id from member mm where mm.user_id = me.id)
85
- or g.email = (select lower(u.email) from "user" u where u.id = me.id and u.email_verified)
95
+ select ${MANAGE} from _ac_chain _ac_c join _ac_teams _ac_x on _ac_x.admins and _ac_x.organization_id = _ac_c.organization_id where _ac_c.depth = 0
86
96
  union all
87
- select l.level from access_link l join reach on l.item_kind = reach.kind and l.item_id = reach.id
88
- where l.token_hash = ${who.linkHash} and (l.expires_at is null or l.expires_at > now())
97
+ select _ac_l.level from access_link _ac_l
98
+ where _ac_l.point_id = (select id from _ac_target) and _ac_l.token_hash = ${who.linkHash} and (_ac_l.expires_at is null or _ac_l.expires_at > now())
89
99
  )
90
- select case when exists (select 1 from root) then coalesce(max(level), 0) else 0 end from levels
100
+ select coalesce(max(level), 0) from _ac_levels
91
101
  )`;
92
102
  }
93
103
  const rows = async (db, query) => (await db.execute(query)).rows;
94
104
  function granteeColumns(to) {
95
105
  const keys = Object.keys(to ?? {});
96
- if (keys.length !== 1 || !["user", "team", "organization", "email"].includes(keys[0])) {
97
- throw new Error("share with exactly one of { user }, { team }, { organization } or { email }");
106
+ if (keys.length !== 1 || !["user", "team", "organization", "email", "everyone"].includes(keys[0])) {
107
+ throw new Error("grant to exactly one of { user }, { team }, { organization }, { email } or { everyone: true }");
108
+ }
109
+ if ("everyone" in to) {
110
+ if (to.everyone !== true)
111
+ throw new Error("a grant to Everyone is { everyone: true }");
112
+ return { user: null, team: null, organization: null, everyone: true, email: null };
98
113
  }
99
114
  const value = Object.values(to)[0];
100
115
  if (typeof value !== "string" || value.trim() === "")
@@ -103,6 +118,7 @@ function granteeColumns(to) {
103
118
  user: "user" in to ? to.user : null,
104
119
  team: "team" in to ? to.team : null,
105
120
  organization: "organization" in to ? to.organization : null,
121
+ everyone: null,
106
122
  email: "email" in to ? to.email.trim().toLowerCase() : null,
107
123
  };
108
124
  }
@@ -113,8 +129,11 @@ function grantee(row) {
113
129
  return { team: row.team_id };
114
130
  if (row.organization_id)
115
131
  return { organization: row.organization_id };
132
+ if (row.everyone)
133
+ return { everyone: true };
116
134
  return { email: row.email };
117
135
  }
136
+ const pgCode = (error) => error.code ?? error.cause?.code;
118
137
  // Postgres refuses a grant to a user, team or organization that does not exist, or whose id is not
119
138
  // a UUID at all; both look like any other missing thing.
120
139
  async function missingAsNotFound(work) {
@@ -122,186 +141,319 @@ async function missingAsNotFound(work) {
122
141
  return await work;
123
142
  }
124
143
  catch (error) {
125
- const code = error.code ?? error.cause?.code;
144
+ const code = pgCode(error);
126
145
  if (code === "23503" || code === "22P02")
127
146
  throw notFound();
128
147
  throw error;
129
148
  }
130
149
  }
131
- export function createAccess(db, caller, notify) {
132
- let who;
133
- const me = () => (who ??= caller().then(async ({ userId, link }) => ({ userId, linkHash: link ? await hashToken(link) : null })));
134
- async function levelOf(on, kind, id) {
135
- checkKind(kind);
136
- checkId(id);
137
- const [row] = await rows(on, sql `select ${levelSql(sql `${kind}`, sql `${id}`, await me())} as level`);
138
- const level = Number(row?.level ?? 0);
139
- if (level === 0)
140
- await reportTooDeep(on, kind, id);
141
- return level;
142
- }
143
- // A chain longer than place allows can only come from writing the tables directly; it is
144
- // refused like any other, and logged, since it means a bug.
145
- async function reportTooDeep(on, kind, id) {
146
- const [up] = await rows(on, chainUp(kind, id, MAX_DEPTH + 1));
147
- if (up && Number(up.depth) >= MAX_DEPTH)
148
- console.error(`access: ${kind} ${id} sits more than ${MAX_DEPTH} items deep; refused`);
149
- }
150
- async function need(on, kind, id, level) {
151
- if ((await levelOf(on, kind, id)) < level)
150
+ const pointRow = async (on, id) => (await rows(on, sql `select id, parent_id, organization_id, for_organization, for_team, inherits, static from access_point where id = ${id}`))[0];
151
+ // One structural change at a time per database, so two moves can never pass each other's loop check.
152
+ const lockTree = (tx) => tx.execute(sql `select pg_advisory_xact_lock(hashtext('@celsoft/platform access tree'))`);
153
+ // The number of points from this one to the top, counting itself.
154
+ async function depthOf(on, id) {
155
+ const [row] = await rows(on, sql `with recursive up as (
156
+ select id, parent_id, 1 as depth from access_point where id = ${id}
157
+ union all
158
+ select p.id, p.parent_id, u.depth + 1 from up u join access_point p on p.id = u.parent_id where u.depth <= ${MAX_DEPTH}
159
+ )
160
+ select coalesce(max(depth), 0) as depth from up`);
161
+ return Number(row?.depth ?? 0);
162
+ }
163
+ // Every point under this one and itself, the point first, as far down as MAX_DEPTH allows.
164
+ async function subtree(on, id) {
165
+ return rows(on, sql `with recursive down as (
166
+ select id, 0 as depth from access_point where id = ${id}
167
+ union all
168
+ select c.id, d.depth + 1 from down d join access_point c on c.parent_id = d.id where d.depth < ${MAX_DEPTH}
169
+ )
170
+ select id, depth from down order by depth, id`);
171
+ }
172
+ // Organization and team grants under an organization must belong to it; `organizationId` null
173
+ // means the points sit under no organization, where any principal may be granted.
174
+ async function foreignGrant(on, ids, organizationId) {
175
+ if (organizationId === null || ids.length === 0)
176
+ return false;
177
+ const [row] = await rows(on, sql `select exists (
178
+ select 1 from access_grant g left join team t on t.id = g.team_id
179
+ where g.point_id in ${ids}
180
+ and ((g.organization_id is not null and g.organization_id <> ${organizationId}::uuid)
181
+ or (g.team_id is not null and t.organization_id <> ${organizationId}::uuid))
182
+ ) as foreign`);
183
+ return Boolean(row?.foreign);
184
+ }
185
+ // Refuses an organization or team grant from another organization on a point under one.
186
+ async function checkSameOrganization(on, point, g) {
187
+ if (!point.organization_id)
188
+ return;
189
+ if (g.organization && g.organization !== point.organization_id)
190
+ throw conflict("a point under an organization can be granted only to that organization, its teams, people and Everyone");
191
+ if (g.team) {
192
+ const [team] = await missingAsNotFound(rows(on, sql `select organization_id from team where id = ${g.team}::uuid`));
193
+ if (!team)
152
194
  throw notFound();
195
+ if (team.organization_id !== point.organization_id)
196
+ throw conflict("a point under an organization can be granted only to that organization, its teams, people and Everyone");
153
197
  }
154
- // The level the top of the tree gives: a user over their own items, a role over an organization's.
155
- async function topLevel(on, kind, id) {
156
- const { userId } = await me();
157
- if (!userId)
158
- return 0;
159
- if (kind === "user")
160
- return id === userId ? MANAGE : 0;
161
- const [row] = await rows(on, sql `select coalesce(max(case btrim(roles.name) when 'owner' then ${MANAGE} when 'admin' then ${DELETE} when 'member' then ${READ} else 0 end), 0) as level
162
- from member m cross join lateral unnest(string_to_array(m.role, ',')) as roles(name)
163
- where m.organization_id::text = ${id} and m.user_id = ${userId}::uuid`);
164
- return Number(row?.level ?? 0);
165
- }
166
- async function place(on, kind, id, parentKind, parentId) {
167
- checkKind(kind);
168
- checkId(id);
169
- checkId(parentId);
170
- await on.transaction(async (raw) => {
171
- const tx = raw;
172
- // One move at a time per database, so two moves can never pass each other's loop check.
173
- await tx.execute(sql `select pg_advisory_xact_lock(hashtext('@celsoft/platform access place'))`);
174
- const [existing] = await rows(tx, sql `select 1 from access_item where kind = ${kind} and id = ${id}`);
175
- if (existing)
176
- await need(tx, kind, id, WRITE);
177
- let above = 0;
178
- if (ROOTS.includes(parentKind)) {
179
- if ((await topLevel(tx, parentKind, parentId)) < CREATE)
180
- throw notFound();
181
- }
182
- else {
183
- await need(tx, parentKind, parentId, CREATE);
184
- const [up] = await rows(tx, sql `${chainUp(parentKind, parentId, MAX_DEPTH)}, (select exists (select 1 from up where kind = ${kind} and id = ${id})) as within`);
185
- if (up?.within)
186
- throw new EndpointError(409, "an item cannot be placed inside itself");
187
- above = Number(up?.depth ?? 0) + 1;
188
- }
189
- const [down] = await rows(tx, sql `with recursive down as (
190
- select kind, id, 1 as depth from access_item where parent_kind = ${kind} and parent_id = ${id}
191
- union all
192
- select c.kind, c.id, d.depth + 1 from down d join access_item c on c.parent_kind = d.kind and c.parent_id = d.id
193
- where d.depth < ${MAX_DEPTH}
194
- )
195
- select coalesce(max(depth), 0) as height from down`);
196
- if (above + 1 + Number(down?.height ?? 0) > MAX_DEPTH)
197
- throw new EndpointError(409, `items can sit at most ${MAX_DEPTH} deep`);
198
- await tx.execute(sql `
199
- insert into access_item (kind, id, parent_kind, parent_id) values (${kind}, ${id}, ${parentKind}, ${parentId})
200
- on conflict (kind, id) do update set parent_kind = excluded.parent_kind, parent_id = excluded.parent_id, updated_at = now()`);
201
- });
202
- }
203
- function bound(on) {
198
+ }
199
+ export function createAccess(db, caller, options = {}) {
200
+ const { notify, ready } = options;
201
+ let human;
202
+ const person = () => (human ??= Promise.all([caller(), ready?.()]).then(async ([{ userId, link }]) => ({ userId, linkHash: link ? await hashToken(link) : null, app: false })));
203
+ const asApp = async () => (await ready?.(), { userId: null, linkHash: null, app: true });
204
+ function bound(on, me) {
205
+ async function levelOf(at, point) {
206
+ checkPointId(point);
207
+ const [row] = await rows(at, sql `select ${levelSql(sql `${point}`, await me())} as level`);
208
+ return Number(row?.level ?? 0);
209
+ }
210
+ async function need(at, point, level) {
211
+ const has = await levelOf(at, point);
212
+ if (has < level)
213
+ throw notFound();
214
+ return has;
215
+ }
216
+ // A point the caller has `level` on, refusing changes to static points, whose grants change
217
+ // only with a deploy, and to the points the platform keeps for organizations and teams where
218
+ // `structural` is set.
219
+ async function changeable(at, point, level, structural = false) {
220
+ await need(at, point, level);
221
+ const row = (await pointRow(at, point));
222
+ if (row.static)
223
+ throw conflict("a static point changes only in code, with a deploy");
224
+ if (structural && (row.for_organization || row.for_team))
225
+ throw conflict("an organization's or team's point changes only through the organization");
226
+ return row;
227
+ }
228
+ async function signedIn() {
229
+ const who = await me();
230
+ if (!who.app && !userParam(who.userId))
231
+ throw new EndpointError(401, "sign in first");
232
+ return who;
233
+ }
234
+ async function insertGrant(at, point, g, n, grantedBy) {
235
+ return missingAsNotFound(rows(at,
236
+ // xmax is 0 only for a row this statement inserted, so a changed grant sends no second email.
237
+ sql `
238
+ insert into access_grant (point_id, user_id, team_id, organization_id, everyone, email, level, granted_by)
239
+ values (${point}, ${g.user}::uuid, ${g.team}::uuid, ${g.organization}::uuid, ${g.everyone}, ${g.email}, ${n}, ${grantedBy}::uuid)
240
+ on conflict on constraint access_grant_once do update set level = excluded.level, granted_by = excluded.granted_by, updated_at = now()
241
+ returning xmax = 0 as inserted,
242
+ coalesce(email, (select u.email from "user" u where u.id = access_grant.user_id)) as recipient,
243
+ (select u.name from "user" u where u.id = ${grantedBy}::uuid) as shared_by`));
244
+ }
245
+ function checkGrant(to, level) {
246
+ const n = rank(level);
247
+ const g = granteeColumns(to);
248
+ if (g.everyone && n !== READ)
249
+ throw new Error(`Everyone can be granted read only, never ${level}`);
250
+ return { g, n };
251
+ }
204
252
  return {
205
- level: async (kind, id) => named(await levelOf(on, kind, id)),
206
- can: async (kind, id, level) => (await levelOf(on, kind, id)) >= rank(level),
207
- require: async (kind, id, level) => need(on, kind, id, rank(level)),
208
- place: (kind, id, parentKind, parentId) => place(on, kind, id, parentKind, parentId),
209
- async remove(kind, id) {
210
- await need(on, kind, id, DELETE);
211
- const [child] = await rows(on, sql `select 1 from access_item where parent_kind = ${kind} and parent_id = ${id} limit 1`);
212
- if (child)
213
- throw new EndpointError(409, "move or delete what is inside first");
214
- await on.execute(sql `delete from access_item where kind = ${kind} and id = ${id}`);
253
+ async point(point) {
254
+ await need(on, point, READ);
255
+ const row = (await pointRow(on, point));
256
+ return { id: row.id, parent: row.parent_id, inherits: row.inherits, static: row.static, organizationId: row.organization_id };
215
257
  },
216
- async restrict(kind, id, restricted = true) {
217
- await need(on, kind, id, MANAGE);
218
- await on.execute(sql `update access_item set restricted = ${restricted}, updated_at = now() where kind = ${kind} and id = ${id}`);
258
+ level: async (point) => named(await levelOf(on, point)),
259
+ can: async (point, level) => (await levelOf(on, point)) >= rank(level),
260
+ check: async (point) => named(await need(on, point, READ)),
261
+ require: async (point, level) => named(await need(on, point, rank(level))),
262
+ async create(point, options = {}) {
263
+ checkPointId(point);
264
+ if (RESERVED.test(point))
265
+ throw new Error(`control point ids starting organization: or team: belong to the platform; got ${JSON.stringify(point)}`);
266
+ const grants = (options.grants ?? []).map(({ to, level }) => checkGrant(to, level));
267
+ const who = await signedIn();
268
+ await on.transaction(async (raw) => {
269
+ const tx = raw;
270
+ await lockTree(tx);
271
+ let organizationId = null;
272
+ if (options.parent !== undefined) {
273
+ await need(tx, options.parent, CREATE);
274
+ const parent = (await pointRow(tx, options.parent));
275
+ if ((await depthOf(tx, options.parent)) >= MAX_DEPTH)
276
+ throw conflict(`points can sit at most ${MAX_DEPTH} deep`);
277
+ organizationId = parent.organization_id;
278
+ }
279
+ const creator = userParam(who.userId);
280
+ try {
281
+ await tx.execute(sql `
282
+ insert into access_point (id, parent_id, organization_id, inherits, created_by)
283
+ values (${point}, ${options.parent ?? null}, ${organizationId}::uuid, ${options.inherit ?? false}, ${creator}::uuid)`);
284
+ }
285
+ catch (error) {
286
+ if (pgCode(error) === "23505")
287
+ throw conflict("that control point already exists");
288
+ throw error;
289
+ }
290
+ const row = (await pointRow(tx, point));
291
+ for (const { g } of grants)
292
+ await checkSameOrganization(tx, row, g);
293
+ if (creator)
294
+ await insertGrant(tx, point, { user: creator, team: null, organization: null, everyone: null, email: null }, MANAGE, creator);
295
+ for (const { g, n } of grants)
296
+ await insertGrant(tx, point, g, n, creator);
297
+ });
219
298
  },
220
- async share(kind, id, to, level, options = {}) {
221
- const n = rank(level);
222
- const g = granteeColumns(to);
299
+ async move(point, parent) {
300
+ checkPointId(point);
301
+ if (parent !== null)
302
+ checkPointId(parent);
303
+ await on.transaction(async (raw) => {
304
+ const tx = raw;
305
+ await lockTree(tx);
306
+ await changeable(tx, point, MANAGE, true);
307
+ let organizationId = null;
308
+ let above = 0;
309
+ if (parent !== null) {
310
+ await need(tx, parent, CREATE);
311
+ organizationId = (await pointRow(tx, parent)).organization_id;
312
+ above = await depthOf(tx, parent);
313
+ }
314
+ const below = await subtree(tx, point);
315
+ const ids = below.map((p) => p.id);
316
+ if (parent !== null && ids.includes(parent))
317
+ throw conflict("a point cannot sit under itself");
318
+ const height = Math.max(...below.map((p) => Number(p.depth))) + 1;
319
+ if (above + height > MAX_DEPTH)
320
+ throw conflict(`points can sit at most ${MAX_DEPTH} deep`);
321
+ if (await foreignGrant(tx, ids, organizationId))
322
+ throw conflict("points under an organization can be granted only to its own teams and itself; revoke the other organization's grants first");
323
+ await tx.execute(sql `update access_point set parent_id = ${parent}, updated_at = now() where id = ${point}`);
324
+ await tx.execute(sql `update access_point set organization_id = ${organizationId}::uuid, updated_at = now() where id in ${ids}`);
325
+ });
326
+ },
327
+ async remove(point) {
328
+ checkPointId(point);
329
+ return on.transaction(async (raw) => {
330
+ const tx = raw;
331
+ await lockTree(tx);
332
+ await changeable(tx, point, DELETE, true);
333
+ const ids = (await subtree(tx, point)).map((p) => p.id);
334
+ await tx.execute(sql `delete from access_point where id in ${ids}`);
335
+ return ids;
336
+ });
337
+ },
338
+ async inherit(point, inherits) {
339
+ await changeable(on, point, MANAGE);
340
+ await on.execute(sql `update access_point set inherits = ${inherits}, updated_at = now() where id = ${point}`);
341
+ },
342
+ async grant(point, to, level, options = {}) {
343
+ const { g, n } = checkGrant(to, level);
223
344
  const email = options.email ?? {};
224
345
  if (email && email.path !== undefined && !/^\/(?![/\\])/.test(email.path))
225
346
  throw new Error(`a sharing email's path must start with one /, such as /pages/7; got ${JSON.stringify(email.path)}`);
226
- await need(on, kind, id, MANAGE);
347
+ const row = await changeable(on, point, MANAGE);
348
+ await checkSameOrganization(on, row, g);
227
349
  const { userId } = await me();
228
- // xmax is 0 only for a row this statement inserted, so a changed grant sends no second email.
229
- const [row] = await missingAsNotFound(rows(on, sql `
230
- insert into access_grant (item_kind, item_id, user_id, team_id, organization_id, email, level, granted_by)
231
- values (${kind}, ${id}, ${g.user}::uuid, ${g.team}::uuid, ${g.organization}::uuid, ${g.email}, ${n}, ${userId}::uuid)
232
- on conflict on constraint access_grant_once do update set level = excluded.level, granted_by = excluded.granted_by, updated_at = now()
233
- returning xmax = 0 as inserted,
234
- coalesce(email, (select u.email from "user" u where u.id = access_grant.user_id)) as recipient,
235
- (select u.name from "user" u where u.id = ${userId}::uuid) as shared_by`));
236
- if (!notify || email === false || !row?.inserted || !row.recipient)
350
+ const [inserted] = await insertGrant(on, point, g, n, userParam(userId));
351
+ if (!notify || email === false || !inserted?.inserted || !inserted.recipient || !(g.user || g.email))
237
352
  return;
238
- await notify({ to: row.recipient, sharedBy: row.shared_by, level, path: email.path ?? "/", subject: email.subject, text: email.text });
353
+ await notify({ to: inserted.recipient, sharedBy: inserted.shared_by, level, path: email.path ?? "/", subject: email.subject, text: email.text });
239
354
  },
240
- async revoke(kind, id, to) {
355
+ async revoke(point, to) {
241
356
  const g = granteeColumns(to);
242
- await need(on, kind, id, MANAGE);
243
- await on.execute(sql `
244
- delete from access_grant where item_kind = ${kind} and item_id = ${id}
245
- and user_id is not distinct from ${g.user}::uuid and team_id is not distinct from ${g.team}::uuid
246
- and organization_id is not distinct from ${g.organization}::uuid and email is not distinct from ${g.email}`);
357
+ await changeable(on, point, MANAGE);
358
+ await missingAsNotFound(on.execute(sql `
359
+ delete from access_grant where point_id = ${point}
360
+ and user_id is not distinct from ${g.user}::uuid and team_id is not distinct from ${g.team}::uuid
361
+ and organization_id is not distinct from ${g.organization}::uuid and everyone is not distinct from ${g.everyone}
362
+ and email is not distinct from ${g.email}`));
247
363
  },
248
- async grants(kind, id) {
249
- await need(on, kind, id, MANAGE);
250
- const found = await rows(on, sql `select user_id, team_id, organization_id, email, level, granted_by from access_grant where item_kind = ${kind} and item_id = ${id} order by created_at, id`);
364
+ async grants(point) {
365
+ await need(on, point, MANAGE);
366
+ const found = await rows(on, sql `select user_id, team_id, organization_id, everyone, email, level, granted_by from access_grant where point_id = ${point} order by created_at, id`);
251
367
  return found.map((row) => ({ to: grantee(row), level: named(Number(row.level)), grantedBy: row.granted_by }));
252
368
  },
253
- async sharedWithMe(kind) {
254
- checkKind(kind);
255
- const who = await me();
256
- if (!who.userId)
257
- return [];
258
- const found = await rows(on, sql `select distinct g.item_id from access_grant g
259
- where g.item_kind = ${kind}
260
- and (g.user_id = ${who.userId}::uuid
261
- or g.team_id in (select team_id from team_member where user_id = ${who.userId}::uuid)
262
- or g.organization_id in (select organization_id from member where user_id = ${who.userId}::uuid)
263
- or g.email = (select lower(email) from "user" where id = ${who.userId}::uuid and email_verified))
264
- and ${levelSql(sql `g.item_kind`, sql `g.item_id`, who)} >= ${READ}
265
- order by g.item_id`);
266
- return found.map((row) => row.item_id);
267
- },
268
- async link(kind, id, level, options = {}) {
369
+ async link(point, level, options = {}) {
269
370
  const n = rank(level);
270
371
  if (n > WRITE)
271
372
  throw new Error(`a link can carry read, create or write, never ${level}`);
272
- await need(on, kind, id, MANAGE);
373
+ if (options.expires !== undefined && !(options.expires instanceof Date && !Number.isNaN(options.expires.getTime())))
374
+ throw new Error("a link's expires must be a valid Date");
375
+ await changeable(on, point, MANAGE);
273
376
  const token = newToken();
274
377
  const { userId } = await me();
275
- const [row] = await rows(on, sql `insert into access_link (item_kind, item_id, token_hash, level, expires_at, created_by)
276
- values (${kind}, ${id}, ${await hashToken(token)}, ${n}, ${options.expires ?? null}, ${userId}::uuid) returning id`);
378
+ const [row] = await rows(on, sql `insert into access_link (point_id, token_hash, level, expires_at, created_by)
379
+ values (${point}, ${await hashToken(token)}, ${n}, ${options.expires ?? null}, ${userParam(userId)}::uuid) returning id`);
277
380
  return { id: row.id, token };
278
381
  },
279
- async links(kind, id) {
280
- await need(on, kind, id, MANAGE);
281
- const found = await rows(on, sql `select id, level, expires_at from access_link where item_kind = ${kind} and item_id = ${id} order by created_at, id`);
382
+ async links(point) {
383
+ await need(on, point, MANAGE);
384
+ const found = await rows(on, sql `select id, level, expires_at from access_link where point_id = ${point} order by created_at, id`);
282
385
  return found.map((row) => ({ id: row.id, level: named(Number(row.level)), expiresAt: row.expires_at ? new Date(row.expires_at) : null }));
283
386
  },
284
- async revokeLink(kind, id, linkId) {
285
- await need(on, kind, id, MANAGE);
286
- await on.execute(sql `delete from access_link where item_kind = ${kind} and item_id = ${id} and id::text = ${linkId}`);
387
+ async revokeLink(point, linkId) {
388
+ await need(on, point, MANAGE);
389
+ await on.execute(sql `delete from access_link where point_id = ${point} and id::text = ${String(linkId)}`);
287
390
  },
288
- async accessible(kind, level, column) {
289
- checkKind(kind);
290
- return sql `(${levelSql(sql `${kind}`, sql `(${column})::text`, await me())} >= ${rank(level)})`;
291
- },
292
- in: (tx) => bound(tx),
391
+ readable: async (column) => sql `(${levelSql(sql `(${column})::text`, await me())} >= ${READ})`,
392
+ allows: async (level, column) => sql `(${levelSql(sql `(${column})::text`, await me())} >= ${rank(level)})`,
393
+ in: (tx) => bound(tx, me),
394
+ asApp: () => bound(on, asApp),
293
395
  };
294
396
  }
295
- return bound(db);
397
+ return bound(db, person);
296
398
  }
297
- // The chain above an item, the item included, as `up`, with `depth` the topmost step reached.
298
- function chainUp(kind, id, limit) {
299
- return sql `with recursive up as (
300
- select kind, id, parent_kind, parent_id, 0 as depth from access_item where kind = ${kind} and id = ${id}
301
- union all
302
- select p.kind, p.id, p.parent_kind, p.parent_id, u.depth + 1 from up u join access_item p on p.kind = u.parent_kind and p.id = u.parent_id
303
- where u.depth < ${limit}
304
- )
305
- select (select max(depth) from up) as depth`;
399
+ // Writes the app's static points, as declared in code: each is created or updated, and its grants
400
+ // replaced. Points no longer declared are left alone. Run once per Worker start, before any check.
401
+ export async function applyStaticPoints(db, points) {
402
+ const names = Object.keys(points);
403
+ if (names.length === 0)
404
+ return;
405
+ for (const name of names) {
406
+ checkPointId(name);
407
+ if (RESERVED.test(name))
408
+ throw new Error(`static point ${JSON.stringify(name)} uses a prefix that belongs to the platform`);
409
+ const parent = points[name].parent;
410
+ if (parent !== undefined && !(parent in points))
411
+ throw new Error(`static point ${name}'s parent ${JSON.stringify(parent)} is not a static point`);
412
+ }
413
+ // Parents first, refusing a loop among them.
414
+ const order = [];
415
+ const visiting = new Set();
416
+ const visit = (name) => {
417
+ if (order.includes(name))
418
+ return;
419
+ if (visiting.has(name))
420
+ throw new Error(`static points ${[...visiting].join(", ")} sit under each other`);
421
+ visiting.add(name);
422
+ const parent = points[name].parent;
423
+ if (parent !== undefined)
424
+ visit(parent);
425
+ visiting.delete(name);
426
+ order.push(name);
427
+ };
428
+ names.forEach(visit);
429
+ const depth = (name) => (points[name].parent ? 1 + depth(points[name].parent) : 1);
430
+ if (names.some((n) => depth(n) > MAX_DEPTH))
431
+ throw new Error(`static points can sit at most ${MAX_DEPTH} deep`);
432
+ await db.transaction(async (raw) => {
433
+ const tx = raw;
434
+ await lockTree(tx);
435
+ for (const name of order) {
436
+ const point = points[name];
437
+ const grants = (point.grants ?? []).map(({ to, level }) => {
438
+ const n = rank(level);
439
+ const g = granteeColumns(to);
440
+ if (g.everyone && n !== READ)
441
+ throw new Error(`static point ${name}: Everyone can be granted read only`);
442
+ return { g, n };
443
+ });
444
+ const [existing] = await rows(tx, sql `select static from access_point where id = ${name}`);
445
+ if (existing && !existing.static)
446
+ throw new Error(`static point ${JSON.stringify(name)} has the id of a point the app created at runtime`);
447
+ await tx.execute(sql `
448
+ insert into access_point (id, parent_id, inherits, static) values (${name}, ${point.parent ?? null}, ${point.inherit ?? false}, true)
449
+ on conflict (id) do update set parent_id = excluded.parent_id, inherits = excluded.inherits, updated_at = now()`);
450
+ await tx.execute(sql `delete from access_grant where point_id = ${name}`);
451
+ for (const { g, n } of grants) {
452
+ await tx.execute(sql `
453
+ insert into access_grant (point_id, user_id, team_id, organization_id, everyone, email, level)
454
+ values (${name}, ${g.user}::uuid, ${g.team}::uuid, ${g.organization}::uuid, ${g.everyone}, ${g.email}, ${n})`);
455
+ }
456
+ }
457
+ });
306
458
  }
307
459
  //# sourceMappingURL=access.js.map