@nacre.work/api 0.3.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/adapters.d.ts +3 -0
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +82 -7
- package/dist/adapters.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +48 -2
- package/dist/main.js.map +1 -1
- package/dist/principals.d.ts +167 -0
- package/dist/principals.d.ts.map +1 -0
- package/dist/principals.js +314 -0
- package/dist/principals.js.map +1 -0
- package/dist/rebuild-collection.d.ts +2 -0
- package/dist/rebuild-collection.d.ts.map +1 -0
- package/dist/rebuild-collection.js +169 -0
- package/dist/rebuild-collection.js.map +1 -0
- package/dist/server.d.ts +48 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +495 -19
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
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,
|
|
@@ -964,34 +984,74 @@ async function handle(req, res, options) {
|
|
|
964
984
|
? body_.external_id
|
|
965
985
|
: (uploaded?.filename ?? undefined);
|
|
966
986
|
let content = body_.content;
|
|
987
|
+
let binary;
|
|
967
988
|
if (uploaded !== undefined) {
|
|
968
989
|
if (typeof content === 'string' || typeof body_.url === 'string') {
|
|
969
990
|
const problem = badRequest(instance, requestId, "A multipart upload carries the document; 'content' and 'url' are for the JSON body.");
|
|
970
991
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
971
992
|
return;
|
|
972
993
|
}
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
//
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
994
|
+
// PDF first, and both signals must agree: the part must declare
|
|
995
|
+
// `application/pdf` AND the bytes must begin with `%PDF-`. Either
|
|
996
|
+
// alone is a refusal that names the other — a declared type the bytes
|
|
997
|
+
// contradict is exactly the disagreement the multipart parser's
|
|
998
|
+
// strictness doctrine exists to refuse, and sniffing alone would turn
|
|
999
|
+
// the declared type into decoration. Other formats extend this table;
|
|
1000
|
+
// nothing falls through to a guess.
|
|
1001
|
+
const declared = (uploaded.contentType ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
|
|
1002
|
+
const MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
|
|
1003
|
+
const magic = uploaded.bytes.length >= MAGIC.length && MAGIC.every((b, i) => uploaded.bytes[i] === b);
|
|
1004
|
+
if (declared === 'application/pdf' && !magic) {
|
|
1005
|
+
const problem = badRequest(instance, requestId, "The file part declares 'application/pdf' but the bytes do not begin with the %PDF- magic. " +
|
|
1006
|
+
'Both must agree; a declared type the bytes contradict is refused rather than trusted.');
|
|
1007
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1008
|
+
return;
|
|
988
1009
|
}
|
|
989
|
-
|
|
990
|
-
const problem = badRequest(instance, requestId,
|
|
991
|
-
'
|
|
1010
|
+
if (declared !== 'application/pdf' && magic) {
|
|
1011
|
+
const problem = badRequest(instance, requestId, "The bytes begin with the %PDF- magic but the file part does not declare 'application/pdf'. " +
|
|
1012
|
+
'Both must agree; declare the type rather than relying on sniffing.');
|
|
992
1013
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
993
1014
|
return;
|
|
994
1015
|
}
|
|
1016
|
+
if (declared === 'application/pdf' && magic) {
|
|
1017
|
+
// Binary requires object storage, at the edge. The bytes' only home
|
|
1018
|
+
// is the bucket — `documents.source_ref` is text and stays text — so
|
|
1019
|
+
// a deployment without one learns on the request, naming the
|
|
1020
|
+
// variables, not from a `failed` row minutes later.
|
|
1021
|
+
if (options.objectStorage !== true) {
|
|
1022
|
+
const problem = badRequest(instance, requestId, 'A PDF upload needs object storage, and this deployment has none configured. ' +
|
|
1023
|
+
'Set NACRE_S3_* (endpoint, bucket, access key, secret key) to enable binary ingest.');
|
|
1024
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
binary = { bytes: uploaded.bytes, contentType: 'application/pdf' };
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
// Decoded here, and refused here, rather than queued and failed
|
|
1031
|
+
// later.
|
|
1032
|
+
//
|
|
1033
|
+
// The parser extracts exactly the formats in the table above — it
|
|
1034
|
+
// took its first dependency for PDF and nothing else. Until this
|
|
1035
|
+
// check existed the sidecar decoded with `errors="replace"`, so a
|
|
1036
|
+
// binary file became a string of replacement characters that was
|
|
1037
|
+
// chunked, embedded, stored as the document body and reported as
|
|
1038
|
+
// indexed.
|
|
1039
|
+
//
|
|
1040
|
+
// At the edge the caller learns immediately and nothing is queued.
|
|
1041
|
+
// Deep in the worker they would have learned from a `failed` row
|
|
1042
|
+
// minutes later, if they looked.
|
|
1043
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
1044
|
+
try {
|
|
1045
|
+
content = decoder.decode(uploaded.bytes);
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
const problem = badRequest(instance, requestId, 'The uploaded file is not UTF-8 text. This installation extracts PDF and nothing else — ' +
|
|
1049
|
+
'a Word file or an image needs an extractor the parser deliberately does not carry, ' +
|
|
1050
|
+
'and a PDF must declare application/pdf on the file part.');
|
|
1051
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
995
1055
|
}
|
|
996
1056
|
const url_ = body_.url;
|
|
997
1057
|
if (typeof layer !== 'string' || typeof externalId !== 'string') {
|
|
@@ -999,7 +1059,10 @@ async function handle(req, res, options) {
|
|
|
999
1059
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
1000
1060
|
return;
|
|
1001
1061
|
}
|
|
1002
|
-
|
|
1062
|
+
// A PDF file part is the third source and already excludes the other
|
|
1063
|
+
// two: `content` and `url` beside a file were refused above, before the
|
|
1064
|
+
// bytes were even looked at.
|
|
1065
|
+
if (binary === undefined && (typeof content === 'string') === (typeof url_ === 'string')) {
|
|
1003
1066
|
// Accepting both would mean choosing silently, and the choice would
|
|
1004
1067
|
// differ from whatever the caller assumed.
|
|
1005
1068
|
const problem = badRequest(instance, requestId, "Exactly one of 'content' or 'url' is required.");
|
|
@@ -1046,6 +1109,7 @@ async function handle(req, res, options) {
|
|
|
1046
1109
|
...(typeof body_.title === 'string' ? { title: body_.title } : {}),
|
|
1047
1110
|
...(typeof content === 'string' ? { content } : {}),
|
|
1048
1111
|
...(typeof url_ === 'string' ? { url: url_ } : {}),
|
|
1112
|
+
...(binary === undefined ? {} : { bytes: binary.bytes, contentType: binary.contentType }),
|
|
1049
1113
|
metadata,
|
|
1050
1114
|
});
|
|
1051
1115
|
// The accept stage only — parse, chunk and embed happen in the worker,
|
|
@@ -1505,6 +1569,35 @@ async function handle(req, res, options) {
|
|
|
1505
1569
|
send(res, 204, null, requestId);
|
|
1506
1570
|
return;
|
|
1507
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
|
+
}
|
|
1508
1601
|
// `/v1/admin/...` — routes a commercial module mounted.
|
|
1509
1602
|
//
|
|
1510
1603
|
// After authentication and after `rejectTenantOverride`, deliberately. A
|
|
@@ -1878,6 +1971,389 @@ async function handle(req, res, options) {
|
|
|
1878
1971
|
send(res, 204, null, requestId);
|
|
1879
1972
|
return;
|
|
1880
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
|
+
}
|
|
1881
2357
|
const problem = notFound(instance, requestId);
|
|
1882
2358
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
1883
2359
|
}
|