@nacre.work/api 0.4.0 → 0.5.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/server.js CHANGED
@@ -8,6 +8,7 @@ import { authenticate, rejectTenantOverride } from './auth.js';
8
8
  import { badRequest, internal, notFound, Problem } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
10
10
  import { limitHeaders } from './limits.js';
11
+ import { looksLikeEmail } from './principals.js';
11
12
  import { clientSource } from './source.js';
12
13
  import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
13
14
  import { readPage } from './pagination.js';
@@ -55,6 +56,25 @@ function accountJson(a) {
55
56
  revoked_at: a.revokedAt,
56
57
  };
57
58
  }
59
+ function userJson(u) {
60
+ return {
61
+ id: u.id,
62
+ email: u.email,
63
+ role: u.role,
64
+ created_at: u.createdAt,
65
+ disabled_at: u.disabledAt,
66
+ // Whether one is set, never anything derived from it. False is an SSO-only
67
+ // account, which is a fact an administrator needs and which says nothing
68
+ // about the credential.
69
+ has_password: u.hasPassword,
70
+ };
71
+ }
72
+ function groupJson(g) {
73
+ return { id: g.id, name: g.name, created_at: g.createdAt, member_count: g.memberCount };
74
+ }
75
+ function memberJson(m) {
76
+ return { type: m.type, id: m.id, label: m.label };
77
+ }
58
78
  function grantJson(g) {
59
79
  return {
60
80
  id: g.id,
@@ -1549,6 +1569,35 @@ async function handle(req, res, options) {
1549
1569
  send(res, 204, null, requestId);
1550
1570
  return;
1551
1571
  }
1572
+ if (req.method === 'DELETE' && layerMatch !== null) {
1573
+ const layerId = layerMatch[1];
1574
+ if (options.layers?.remove === undefined) {
1575
+ const problem = notFound(instance, requestId);
1576
+ send(res, problem.status, problem.toJSON(), requestId);
1577
+ return;
1578
+ }
1579
+ const removed = await options.layers.remove(auth, layerId);
1580
+ // Recorded whichever way it went, and before the answer. Deleting a layer
1581
+ // takes every document in it out of every answer at once, which is the
1582
+ // largest single thing a caller can do here — a refused attempt is worth
1583
+ // as much to an investigation as a successful one.
1584
+ await options.audit.write({
1585
+ orgId: auth.orgId,
1586
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1587
+ action: 'delete_layer',
1588
+ result: removed ? 'allow' : 'deny',
1589
+ target: { layer_id: layerId },
1590
+ detail: { layer_id: layerId },
1591
+ requestId,
1592
+ });
1593
+ if (!removed) {
1594
+ const problem = notFound(instance, requestId);
1595
+ send(res, problem.status, problem.toJSON(), requestId);
1596
+ return;
1597
+ }
1598
+ send(res, 204, null, requestId);
1599
+ return;
1600
+ }
1552
1601
  // `/v1/admin/...` — routes a commercial module mounted.
1553
1602
  //
1554
1603
  // After authentication and after `rejectTenantOverride`, deliberately. A
@@ -1922,6 +1971,389 @@ async function handle(req, res, options) {
1922
1971
  send(res, 204, null, requestId);
1923
1972
  return;
1924
1973
  }
1974
+ // ───────────────────────────── principals ─────────────────────────────
1975
+ //
1976
+ // Users and groups. `org_admin`, on the same argument service accounts
1977
+ // make: a principal belongs to the organization rather than to a scope
1978
+ // inside it, so there is nothing to check `admin` against — and someone
1979
+ // holding admin on one layer must not be able to mint one.
1980
+ //
1981
+ // The refusal writes a `deny` event. It surfaces as `404`, which is what
1982
+ // makes it easy to miss: an early return that answers "no such path" is
1983
+ // still a refusal, and `docs/audit.md` counts every one.
1984
+ const principalPath = /^\/v1\/(users|groups)(\/.*)?$/.exec(instance);
1985
+ if (principalPath !== null) {
1986
+ const port = principalPath[1] === 'users' ? options.users : options.groups;
1987
+ if (port !== undefined && auth.role !== 'org_admin') {
1988
+ await options.audit.write({
1989
+ orgId: auth.orgId,
1990
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1991
+ action: 'administer_principals',
1992
+ result: 'deny',
1993
+ detail: { path: instance, method: req.method ?? 'GET', reason: 'not an org_admin' },
1994
+ requestId,
1995
+ });
1996
+ const problem = notFound(instance, requestId);
1997
+ send(res, problem.status, problem.toJSON(), requestId);
1998
+ return;
1999
+ }
2000
+ }
2001
+ if (instance === '/v1/users' && options.users !== undefined) {
2002
+ if (req.method === 'GET') {
2003
+ const page = readPage(url.searchParams, instance, requestId);
2004
+ if (page instanceof Problem) {
2005
+ send(res, page.status, page.toJSON(), requestId);
2006
+ return;
2007
+ }
2008
+ const { items, nextCursor } = await options.users.list(auth, page);
2009
+ send(res, 200, { items: items.map(userJson), next_cursor: nextCursor }, requestId);
2010
+ return;
2011
+ }
2012
+ if (req.method === 'POST') {
2013
+ const fields = (body ?? {});
2014
+ const email = typeof fields.email === 'string' ? fields.email.trim() : '';
2015
+ if (!looksLikeEmail(email)) {
2016
+ const problem = badRequest(instance, requestId, "'email' is required and must be an address.");
2017
+ send(res, problem.status, problem.toJSON(), requestId);
2018
+ return;
2019
+ }
2020
+ const role = fields.role ?? 'member';
2021
+ // `platform_admin` is deliberately not creatable here, and this is the
2022
+ // one refusal in this block that is about the model rather than about
2023
+ // input. That role administers the *installation* and spans tenants in
2024
+ // the multi-tenancy module; an org_admin minting one would be
2025
+ // escalating out of their own organization through an endpoint scoped
2026
+ // to it. It is set by whoever runs `init`, and stays there.
2027
+ if (role !== 'member' && role !== 'org_admin') {
2028
+ const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
2029
+ 'installation rather than this organization and is not issued here.');
2030
+ send(res, problem.status, problem.toJSON(), requestId);
2031
+ return;
2032
+ }
2033
+ const created = await options.users.create(auth, email, role);
2034
+ if (created === undefined) {
2035
+ await options.audit.write({
2036
+ orgId: auth.orgId,
2037
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2038
+ action: 'create_user',
2039
+ result: 'deny',
2040
+ detail: { email, reason: 'address taken' },
2041
+ requestId,
2042
+ });
2043
+ const problem = new Problem({
2044
+ type: 'https://nacre.work/errors/conflict',
2045
+ title: 'Conflict',
2046
+ status: 409,
2047
+ detail: `A user with the address '${email}' already exists in this organization.`,
2048
+ instance,
2049
+ requestId,
2050
+ });
2051
+ send(res, problem.status, problem.toJSON(), requestId);
2052
+ return;
2053
+ }
2054
+ await options.audit.write({
2055
+ orgId: auth.orgId,
2056
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2057
+ action: 'create_user',
2058
+ result: 'allow',
2059
+ // The address and the role. Never the password — this row is readable
2060
+ // by anyone with the audit log, and the password is not recoverable
2061
+ // from anywhere else by design.
2062
+ detail: { user_id: created.user.id, email, role },
2063
+ requestId,
2064
+ });
2065
+ // The only time the password exists outside the caller's process.
2066
+ send(res, 201, { ...userJson(created.user), password: created.password }, requestId);
2067
+ return;
2068
+ }
2069
+ }
2070
+ const userMatch = /^\/v1\/users\/([^/]+)$/.exec(instance);
2071
+ if (userMatch && options.users !== undefined) {
2072
+ const id = decodeURIComponent(userMatch[1]);
2073
+ if (req.method === 'DELETE') {
2074
+ // Disabled, never deleted, which is what `DELETE` means on every
2075
+ // removable thing here: a document is tombstoned, a key is revoked, and
2076
+ // a user keeps their row because the audit log names its id and
2077
+ // `grants.created_by` references it with no cascade. `PATCH` with
2078
+ // `disabled: false` is how somebody comes back.
2079
+ //
2080
+ // Through the same call `PATCH` makes rather than a second statement,
2081
+ // so the last-administrator guard covers both spellings. Two removals
2082
+ // with one check between them is how the guarded one gets routed
2083
+ // around.
2084
+ const disabled = await options.users.update(auth, id, { disabled: true });
2085
+ await options.audit.write({
2086
+ orgId: auth.orgId,
2087
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2088
+ action: 'disable_user',
2089
+ result: disabled === 'updated' ? 'allow' : 'deny',
2090
+ detail: { user_id: id, ...(disabled === 'updated' ? {} : { reason: disabled }) },
2091
+ requestId,
2092
+ });
2093
+ if (disabled === 'last-admin') {
2094
+ const problem = new Problem({
2095
+ type: 'https://nacre.work/errors/conflict',
2096
+ title: 'Conflict',
2097
+ status: 409,
2098
+ detail: 'This is the only active org_admin in the organization. Promote another user ' +
2099
+ 'first — an organization with none has no route back through the API.',
2100
+ instance,
2101
+ requestId,
2102
+ });
2103
+ send(res, problem.status, problem.toJSON(), requestId);
2104
+ return;
2105
+ }
2106
+ if (disabled === 'no-user') {
2107
+ const problem = notFound(instance, requestId);
2108
+ send(res, problem.status, problem.toJSON(), requestId);
2109
+ return;
2110
+ }
2111
+ send(res, 204, null, requestId);
2112
+ return;
2113
+ }
2114
+ if (req.method === 'PATCH') {
2115
+ const fields = (body ?? {});
2116
+ const wantsRole = 'role' in fields;
2117
+ const wantsDisabled = 'disabled' in fields;
2118
+ if (!wantsRole && !wantsDisabled) {
2119
+ const problem = badRequest(instance, requestId, "Give at least one of 'role' or 'disabled'.");
2120
+ send(res, problem.status, problem.toJSON(), requestId);
2121
+ return;
2122
+ }
2123
+ if (wantsRole && fields.role !== 'member' && fields.role !== 'org_admin') {
2124
+ const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
2125
+ 'installation rather than this organization and is not issued here.');
2126
+ send(res, problem.status, problem.toJSON(), requestId);
2127
+ return;
2128
+ }
2129
+ if (wantsDisabled && typeof fields.disabled !== 'boolean') {
2130
+ const problem = badRequest(instance, requestId, "'disabled' must be a boolean.");
2131
+ send(res, problem.status, problem.toJSON(), requestId);
2132
+ return;
2133
+ }
2134
+ // The last administrator cannot demote or disable themselves through
2135
+ // this path. Not paternalism: an organization with no `org_admin` has
2136
+ // no route back — every endpoint that could restore one is behind the
2137
+ // role that was just given up, and the remedy would be SQL. The check
2138
+ // is in the adapter, where it can count in the same transaction.
2139
+ const changed = await options.users.update(auth, id, {
2140
+ ...(wantsRole ? { role: fields.role } : {}),
2141
+ ...(wantsDisabled ? { disabled: fields.disabled } : {}),
2142
+ });
2143
+ await options.audit.write({
2144
+ orgId: auth.orgId,
2145
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2146
+ action: 'update_user',
2147
+ result: changed === 'updated' ? 'allow' : 'deny',
2148
+ detail: {
2149
+ user_id: id,
2150
+ ...(wantsRole ? { role: fields.role } : {}),
2151
+ ...(wantsDisabled ? { disabled: fields.disabled } : {}),
2152
+ ...(changed === 'updated' ? {} : { reason: changed }),
2153
+ },
2154
+ requestId,
2155
+ });
2156
+ if (changed === 'last-admin') {
2157
+ // 409 rather than 404: the caller is looking straight at this user —
2158
+ // it is their own account or one they just listed — so the answer is
2159
+ // about the organization's state and not about what they can see.
2160
+ // Invariant 4 is about invisibility, and nothing here is invisible.
2161
+ const problem = new Problem({
2162
+ type: 'https://nacre.work/errors/conflict',
2163
+ title: 'Conflict',
2164
+ status: 409,
2165
+ detail: 'This is the only active org_admin in the organization. Promote another user ' +
2166
+ 'first — an organization with none has no route back through the API.',
2167
+ instance,
2168
+ requestId,
2169
+ });
2170
+ send(res, problem.status, problem.toJSON(), requestId);
2171
+ return;
2172
+ }
2173
+ if (changed === 'no-user') {
2174
+ const problem = notFound(instance, requestId);
2175
+ send(res, problem.status, problem.toJSON(), requestId);
2176
+ return;
2177
+ }
2178
+ send(res, 204, null, requestId);
2179
+ return;
2180
+ }
2181
+ }
2182
+ const passwordMatch = /^\/v1\/users\/([^/]+)\/password$/.exec(instance);
2183
+ if (req.method === 'POST' && passwordMatch && options.users !== undefined) {
2184
+ const id = decodeURIComponent(passwordMatch[1]);
2185
+ const password = await options.users.resetPassword(auth, id);
2186
+ await options.audit.write({
2187
+ orgId: auth.orgId,
2188
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2189
+ action: 'reset_password',
2190
+ result: password === undefined ? 'deny' : 'allow',
2191
+ // That it happened and to whom. Never the value.
2192
+ detail: { user_id: id },
2193
+ requestId,
2194
+ });
2195
+ if (password === undefined) {
2196
+ const problem = notFound(instance, requestId);
2197
+ send(res, problem.status, problem.toJSON(), requestId);
2198
+ return;
2199
+ }
2200
+ // Generated rather than chosen, for the reason `init` gives: an argument
2201
+ // ends up in a shell history, and a password an administrator picked is
2202
+ // one they know. This is the only time it exists outside the process.
2203
+ send(res, 200, { password }, requestId);
2204
+ return;
2205
+ }
2206
+ if (instance === '/v1/groups' && options.groups !== undefined) {
2207
+ if (req.method === 'GET') {
2208
+ const page = readPage(url.searchParams, instance, requestId);
2209
+ if (page instanceof Problem) {
2210
+ send(res, page.status, page.toJSON(), requestId);
2211
+ return;
2212
+ }
2213
+ const { items, nextCursor } = await options.groups.list(auth, page);
2214
+ send(res, 200, { items: items.map(groupJson), next_cursor: nextCursor }, requestId);
2215
+ return;
2216
+ }
2217
+ if (req.method === 'POST') {
2218
+ const name = (body ?? {}).name;
2219
+ if (typeof name !== 'string' || name.trim().length === 0) {
2220
+ const problem = badRequest(instance, requestId, "'name' is required.");
2221
+ send(res, problem.status, problem.toJSON(), requestId);
2222
+ return;
2223
+ }
2224
+ const created = await options.groups.create(auth, name.trim());
2225
+ if (created === undefined) {
2226
+ await options.audit.write({
2227
+ orgId: auth.orgId,
2228
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2229
+ action: 'create_group',
2230
+ result: 'deny',
2231
+ detail: { name: name.trim(), reason: 'name taken' },
2232
+ requestId,
2233
+ });
2234
+ const problem = new Problem({
2235
+ type: 'https://nacre.work/errors/conflict',
2236
+ title: 'Conflict',
2237
+ status: 409,
2238
+ detail: `A group named '${name.trim()}' already exists in this organization.`,
2239
+ instance,
2240
+ requestId,
2241
+ });
2242
+ send(res, problem.status, problem.toJSON(), requestId);
2243
+ return;
2244
+ }
2245
+ await options.audit.write({
2246
+ orgId: auth.orgId,
2247
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2248
+ action: 'create_group',
2249
+ result: 'allow',
2250
+ detail: { group_id: created.id, name: created.name },
2251
+ requestId,
2252
+ });
2253
+ send(res, 201, groupJson(created), requestId);
2254
+ return;
2255
+ }
2256
+ }
2257
+ const groupMatch = /^\/v1\/groups\/([^/]+)$/.exec(instance);
2258
+ if (req.method === 'DELETE' && groupMatch && options.groups !== undefined) {
2259
+ const id = decodeURIComponent(groupMatch[1]);
2260
+ const removed = await options.groups.remove(auth, id);
2261
+ await options.audit.write({
2262
+ orgId: auth.orgId,
2263
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2264
+ action: 'delete_group',
2265
+ result: removed ? 'allow' : 'deny',
2266
+ detail: { group_id: id },
2267
+ requestId,
2268
+ });
2269
+ if (!removed) {
2270
+ const problem = notFound(instance, requestId);
2271
+ send(res, problem.status, problem.toJSON(), requestId);
2272
+ return;
2273
+ }
2274
+ send(res, 204, null, requestId);
2275
+ return;
2276
+ }
2277
+ const membersMatch = /^\/v1\/groups\/([^/]+)\/members$/.exec(instance);
2278
+ if (membersMatch && options.groups !== undefined) {
2279
+ const groupId = decodeURIComponent(membersMatch[1]);
2280
+ if (req.method === 'GET') {
2281
+ const page = readPage(url.searchParams, instance, requestId);
2282
+ if (page instanceof Problem) {
2283
+ send(res, page.status, page.toJSON(), requestId);
2284
+ return;
2285
+ }
2286
+ const result = await options.groups.members(auth, groupId, page);
2287
+ if (result === undefined) {
2288
+ const problem = notFound(instance, requestId);
2289
+ send(res, problem.status, problem.toJSON(), requestId);
2290
+ return;
2291
+ }
2292
+ send(res, 200, { items: result.items.map(memberJson), next_cursor: result.nextCursor }, requestId);
2293
+ return;
2294
+ }
2295
+ if (req.method === 'POST') {
2296
+ const fields = (body ?? {});
2297
+ const type = fields.type;
2298
+ const memberId = fields.id;
2299
+ if ((type !== 'user' && type !== 'group') || typeof memberId !== 'string') {
2300
+ const problem = badRequest(instance, requestId, "'type' must be 'user' or 'group', and 'id' is required.");
2301
+ send(res, problem.status, problem.toJSON(), requestId);
2302
+ return;
2303
+ }
2304
+ const outcome = await options.groups.addMember(auth, groupId, { type, id: memberId });
2305
+ await options.audit.write({
2306
+ orgId: auth.orgId,
2307
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2308
+ action: 'add_group_member',
2309
+ result: outcome === 'no-group' || outcome === 'no-member' ? 'deny' : 'allow',
2310
+ detail: {
2311
+ group_id: groupId,
2312
+ member_type: type,
2313
+ member_id: memberId,
2314
+ ...(outcome === 'already' ? { note: 'already a member' } : {}),
2315
+ ...(outcome === 'no-group' || outcome === 'no-member' ? { reason: outcome } : {}),
2316
+ },
2317
+ requestId,
2318
+ });
2319
+ if (outcome === 'no-group' || outcome === 'no-member') {
2320
+ const problem = notFound(instance, requestId);
2321
+ send(res, problem.status, problem.toJSON(), requestId);
2322
+ return;
2323
+ }
2324
+ // 204 for both `added` and `already`. The request asked for a state and
2325
+ // that state holds either way, and distinguishing them would tell a
2326
+ // caller whether somebody was already in a group — which is a fact
2327
+ // about the group and not about their request.
2328
+ send(res, 204, null, requestId);
2329
+ return;
2330
+ }
2331
+ }
2332
+ // `{type}/{id}` rather than `{id}` alone: the edge is keyed by which member
2333
+ // column it uses, so a bare uuid does not identify one. Same shape `grants`
2334
+ // uses for the other end of the same relationship.
2335
+ const memberMatch = /^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/.exec(instance);
2336
+ if (req.method === 'DELETE' && memberMatch && options.groups !== undefined) {
2337
+ const groupId = decodeURIComponent(memberMatch[1]);
2338
+ const type = memberMatch[2];
2339
+ const memberId = decodeURIComponent(memberMatch[3]);
2340
+ const removed = await options.groups.removeMember(auth, groupId, { type, id: memberId });
2341
+ await options.audit.write({
2342
+ orgId: auth.orgId,
2343
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2344
+ action: 'remove_group_member',
2345
+ result: removed ? 'allow' : 'deny',
2346
+ detail: { group_id: groupId, member_type: type, member_id: memberId },
2347
+ requestId,
2348
+ });
2349
+ if (!removed) {
2350
+ const problem = notFound(instance, requestId);
2351
+ send(res, problem.status, problem.toJSON(), requestId);
2352
+ return;
2353
+ }
2354
+ send(res, 204, null, requestId);
2355
+ return;
2356
+ }
1925
2357
  const problem = notFound(instance, requestId);
1926
2358
  send(res, problem.status, problem.toJSON(), requestId);
1927
2359
  }