@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/adapters.d.ts +3 -0
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +45 -0
- 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 +45 -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/server.d.ts +30 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +432 -0
- 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,
|
|
@@ -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
|
}
|