@celsoft/platform 0.15.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.
- package/dist/config.d.ts +3 -0
- package/dist/config.js +11 -1
- package/dist/config.js.map +1 -1
- package/dist/lifecycle.js +25 -1
- package/dist/lifecycle.js.map +1 -1
- package/dist/runtime/access.d.ts +52 -15
- package/dist/runtime/access.js +351 -199
- package/dist/runtime/access.js.map +1 -1
- package/dist/runtime/auth.d.ts +0 -212
- package/dist/runtime/auth.js +3 -13
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/email.d.ts +1 -0
- package/dist/runtime/email.js +7 -3
- package/dist/runtime/email.js.map +1 -1
- package/dist/runtime/endpoints.d.ts +9 -6
- package/dist/runtime/endpoints.js +26 -7
- package/dist/runtime/endpoints.js.map +1 -1
- package/dist/runtime/index.d.ts +8 -2
- package/dist/runtime/index.js +51 -14
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/organizations.d.ts +77 -0
- package/dist/runtime/organizations.js +326 -0
- package/dist/runtime/organizations.js.map +1 -0
- package/dist/runtime/screens.js +1 -1
- package/dist/schema.d.ts +2837 -3039
- package/dist/schema.js +94 -90
- package/dist/schema.js.map +1 -1
- package/package.json +1 -1
- package/schema.json +26 -1
- package/skills/celsoft-platform/SKILL.md +30 -18
package/dist/runtime/access.js
CHANGED
|
@@ -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
|
|
6
|
-
// docs/access-rules.md. Every check is one query against the app's own
|
|
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
|
|
9
|
+
// The longest chain from a point up through its parents, counting the point itself.
|
|
9
10
|
export const MAX_DEPTH = 64;
|
|
10
|
-
const
|
|
11
|
-
const
|
|
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
|
|
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(`
|
|
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
|
-
|
|
48
|
-
// user
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
57
|
-
from
|
|
58
|
-
where
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
88
|
-
where
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
347
|
+
const row = await changeable(on, point, MANAGE);
|
|
348
|
+
await checkSameOrganization(on, row, g);
|
|
227
349
|
const { userId } = await me();
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
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(
|
|
355
|
+
async revoke(point, to) {
|
|
241
356
|
const g = granteeColumns(to);
|
|
242
|
-
await
|
|
243
|
-
await on.execute(sql `
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
249
|
-
await need(on,
|
|
250
|
-
const found = await rows(on, sql `select user_id, team_id, organization_id, email, level, granted_by from access_grant where
|
|
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
|
|
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
|
-
|
|
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 (
|
|
276
|
-
values (${
|
|
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(
|
|
280
|
-
await need(on,
|
|
281
|
-
const found = await rows(on, sql `select id, level, expires_at from access_link where
|
|
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(
|
|
285
|
-
await need(on,
|
|
286
|
-
await on.execute(sql `delete from access_link where
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|