@nacre.work/api 0.4.0 → 0.5.1
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 +72 -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 +478 -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,
|
|
@@ -81,6 +101,8 @@ function grantJson(g) {
|
|
|
81
101
|
* this build does not have, which is not a question about whether an object
|
|
82
102
|
* exists, so invariant I4 has no bearing and saying so plainly is right.
|
|
83
103
|
*/
|
|
104
|
+
/** Shape only. Existence is the adapter's, and answers `404` rather than `400`. */
|
|
105
|
+
const UUID_SHAPE = /^[0-9a-f-]{36}$/i;
|
|
84
106
|
function parseGrant(body) {
|
|
85
107
|
const principalType = body.principal_type;
|
|
86
108
|
const principalId = body.principal_id;
|
|
@@ -91,6 +113,24 @@ function parseGrant(body) {
|
|
|
91
113
|
if (typeof principalId !== 'string' || typeof scopeId !== 'string') {
|
|
92
114
|
return "'principal_id' and 'scope_id' are required.";
|
|
93
115
|
}
|
|
116
|
+
// Shape, here, and existence in the adapter — because the two answers are
|
|
117
|
+
// different and only one of them is allowed to be specific.
|
|
118
|
+
//
|
|
119
|
+
// A value that is not a uuid is a fact about the caller's own request: it
|
|
120
|
+
// discloses nothing, so it gets a `400` naming the field they got wrong.
|
|
121
|
+
// A well-formed uuid that names nothing is `404`, indistinguishable from one
|
|
122
|
+
// they may not administer, which is invariant 4.
|
|
123
|
+
//
|
|
124
|
+
// Collapsing the two is what sent somebody looking at the wrong half of a
|
|
125
|
+
// form: they typed a service account's *name* into `principal_id`, and the
|
|
126
|
+
// only answer was "no such scope" — about the field that was correct.
|
|
127
|
+
if (!UUID_SHAPE.test(principalId)) {
|
|
128
|
+
return "'principal_id' must be a uuid. It is the principal's id, not its name — " +
|
|
129
|
+
'a service account, a user or a group listed in this organization.';
|
|
130
|
+
}
|
|
131
|
+
if (!UUID_SHAPE.test(scopeId)) {
|
|
132
|
+
return "'scope_id' must be a uuid — the id of the workspace or layer being granted on.";
|
|
133
|
+
}
|
|
94
134
|
if (typeof principalType !== 'string' || !PRINCIPAL_TYPES.includes(principalType)) {
|
|
95
135
|
return `'principal_type' must be one of ${PRINCIPAL_TYPES.join(', ')}.`;
|
|
96
136
|
}
|
|
@@ -699,6 +739,32 @@ async function handle(req, res, options) {
|
|
|
699
739
|
await handleAuth(req, res, instance, requestId, options);
|
|
700
740
|
return;
|
|
701
741
|
}
|
|
742
|
+
// A path this server does not route is `404`, and it says so **before**
|
|
743
|
+
// asking for a credential.
|
|
744
|
+
//
|
|
745
|
+
// Everything unauthenticated is already handled above — `/metrics`, the two
|
|
746
|
+
// `/.well-known` documents, health, readiness, sign-in. So anything left that
|
|
747
|
+
// is not under `/v1/` is not part of this API at all, and answering `401`
|
|
748
|
+
// for it claims a path exists and is merely gated.
|
|
749
|
+
//
|
|
750
|
+
// That is not a hypothetical reading. A client pointed at this port looking
|
|
751
|
+
// for an MCP endpoint probed `/.well-known/oauth-authorization-server`,
|
|
752
|
+
// `/.well-known/openid-configuration` and `/register`, got `401 "A bearer
|
|
753
|
+
// token is required"` from each, and concluded the deployment had OAuth
|
|
754
|
+
// discovery behind a middleware that needed lifting. There is no such
|
|
755
|
+
// middleware and there are no such routes: this is a resource server and
|
|
756
|
+
// declines the authorization-server role — see docs/mcp.md. An afternoon
|
|
757
|
+
// went into un-gating endpoints that do not exist.
|
|
758
|
+
//
|
|
759
|
+
// Unknown paths *under* `/v1/` still answer `401`, and that is the line
|
|
760
|
+
// rather than an omission: they are inside the authenticated surface, where
|
|
761
|
+
// presenting a credential is the price of being told anything. Nothing is
|
|
762
|
+
// concealed by it — every route this API serves is in docs/openapi.yaml.
|
|
763
|
+
if (!instance.startsWith('/v1/')) {
|
|
764
|
+
const problem = notFound(instance, requestId);
|
|
765
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
702
768
|
const auth = await authenticate(req.headers.authorization, options.verify, instance, requestId);
|
|
703
769
|
if (auth instanceof Problem) {
|
|
704
770
|
// Counted by what was presented, never by why it failed.
|
|
@@ -1549,6 +1615,35 @@ async function handle(req, res, options) {
|
|
|
1549
1615
|
send(res, 204, null, requestId);
|
|
1550
1616
|
return;
|
|
1551
1617
|
}
|
|
1618
|
+
if (req.method === 'DELETE' && layerMatch !== null) {
|
|
1619
|
+
const layerId = layerMatch[1];
|
|
1620
|
+
if (options.layers?.remove === undefined) {
|
|
1621
|
+
const problem = notFound(instance, requestId);
|
|
1622
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const removed = await options.layers.remove(auth, layerId);
|
|
1626
|
+
// Recorded whichever way it went, and before the answer. Deleting a layer
|
|
1627
|
+
// takes every document in it out of every answer at once, which is the
|
|
1628
|
+
// largest single thing a caller can do here — a refused attempt is worth
|
|
1629
|
+
// as much to an investigation as a successful one.
|
|
1630
|
+
await options.audit.write({
|
|
1631
|
+
orgId: auth.orgId,
|
|
1632
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
1633
|
+
action: 'delete_layer',
|
|
1634
|
+
result: removed ? 'allow' : 'deny',
|
|
1635
|
+
target: { layer_id: layerId },
|
|
1636
|
+
detail: { layer_id: layerId },
|
|
1637
|
+
requestId,
|
|
1638
|
+
});
|
|
1639
|
+
if (!removed) {
|
|
1640
|
+
const problem = notFound(instance, requestId);
|
|
1641
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
send(res, 204, null, requestId);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1552
1647
|
// `/v1/admin/...` — routes a commercial module mounted.
|
|
1553
1648
|
//
|
|
1554
1649
|
// After authentication and after `rejectTenantOverride`, deliberately. A
|
|
@@ -1922,6 +2017,389 @@ async function handle(req, res, options) {
|
|
|
1922
2017
|
send(res, 204, null, requestId);
|
|
1923
2018
|
return;
|
|
1924
2019
|
}
|
|
2020
|
+
// ───────────────────────────── principals ─────────────────────────────
|
|
2021
|
+
//
|
|
2022
|
+
// Users and groups. `org_admin`, on the same argument service accounts
|
|
2023
|
+
// make: a principal belongs to the organization rather than to a scope
|
|
2024
|
+
// inside it, so there is nothing to check `admin` against — and someone
|
|
2025
|
+
// holding admin on one layer must not be able to mint one.
|
|
2026
|
+
//
|
|
2027
|
+
// The refusal writes a `deny` event. It surfaces as `404`, which is what
|
|
2028
|
+
// makes it easy to miss: an early return that answers "no such path" is
|
|
2029
|
+
// still a refusal, and `docs/audit.md` counts every one.
|
|
2030
|
+
const principalPath = /^\/v1\/(users|groups)(\/.*)?$/.exec(instance);
|
|
2031
|
+
if (principalPath !== null) {
|
|
2032
|
+
const port = principalPath[1] === 'users' ? options.users : options.groups;
|
|
2033
|
+
if (port !== undefined && auth.role !== 'org_admin') {
|
|
2034
|
+
await options.audit.write({
|
|
2035
|
+
orgId: auth.orgId,
|
|
2036
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2037
|
+
action: 'administer_principals',
|
|
2038
|
+
result: 'deny',
|
|
2039
|
+
detail: { path: instance, method: req.method ?? 'GET', reason: 'not an org_admin' },
|
|
2040
|
+
requestId,
|
|
2041
|
+
});
|
|
2042
|
+
const problem = notFound(instance, requestId);
|
|
2043
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
if (instance === '/v1/users' && options.users !== undefined) {
|
|
2048
|
+
if (req.method === 'GET') {
|
|
2049
|
+
const page = readPage(url.searchParams, instance, requestId);
|
|
2050
|
+
if (page instanceof Problem) {
|
|
2051
|
+
send(res, page.status, page.toJSON(), requestId);
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
const { items, nextCursor } = await options.users.list(auth, page);
|
|
2055
|
+
send(res, 200, { items: items.map(userJson), next_cursor: nextCursor }, requestId);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
if (req.method === 'POST') {
|
|
2059
|
+
const fields = (body ?? {});
|
|
2060
|
+
const email = typeof fields.email === 'string' ? fields.email.trim() : '';
|
|
2061
|
+
if (!looksLikeEmail(email)) {
|
|
2062
|
+
const problem = badRequest(instance, requestId, "'email' is required and must be an address.");
|
|
2063
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
const role = fields.role ?? 'member';
|
|
2067
|
+
// `platform_admin` is deliberately not creatable here, and this is the
|
|
2068
|
+
// one refusal in this block that is about the model rather than about
|
|
2069
|
+
// input. That role administers the *installation* and spans tenants in
|
|
2070
|
+
// the multi-tenancy module; an org_admin minting one would be
|
|
2071
|
+
// escalating out of their own organization through an endpoint scoped
|
|
2072
|
+
// to it. It is set by whoever runs `init`, and stays there.
|
|
2073
|
+
if (role !== 'member' && role !== 'org_admin') {
|
|
2074
|
+
const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
|
|
2075
|
+
'installation rather than this organization and is not issued here.');
|
|
2076
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const created = await options.users.create(auth, email, role);
|
|
2080
|
+
if (created === undefined) {
|
|
2081
|
+
await options.audit.write({
|
|
2082
|
+
orgId: auth.orgId,
|
|
2083
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2084
|
+
action: 'create_user',
|
|
2085
|
+
result: 'deny',
|
|
2086
|
+
detail: { email, reason: 'address taken' },
|
|
2087
|
+
requestId,
|
|
2088
|
+
});
|
|
2089
|
+
const problem = new Problem({
|
|
2090
|
+
type: 'https://nacre.work/errors/conflict',
|
|
2091
|
+
title: 'Conflict',
|
|
2092
|
+
status: 409,
|
|
2093
|
+
detail: `A user with the address '${email}' already exists in this organization.`,
|
|
2094
|
+
instance,
|
|
2095
|
+
requestId,
|
|
2096
|
+
});
|
|
2097
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
await options.audit.write({
|
|
2101
|
+
orgId: auth.orgId,
|
|
2102
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2103
|
+
action: 'create_user',
|
|
2104
|
+
result: 'allow',
|
|
2105
|
+
// The address and the role. Never the password — this row is readable
|
|
2106
|
+
// by anyone with the audit log, and the password is not recoverable
|
|
2107
|
+
// from anywhere else by design.
|
|
2108
|
+
detail: { user_id: created.user.id, email, role },
|
|
2109
|
+
requestId,
|
|
2110
|
+
});
|
|
2111
|
+
// The only time the password exists outside the caller's process.
|
|
2112
|
+
send(res, 201, { ...userJson(created.user), password: created.password }, requestId);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
const userMatch = /^\/v1\/users\/([^/]+)$/.exec(instance);
|
|
2117
|
+
if (userMatch && options.users !== undefined) {
|
|
2118
|
+
const id = decodeURIComponent(userMatch[1]);
|
|
2119
|
+
if (req.method === 'DELETE') {
|
|
2120
|
+
// Disabled, never deleted, which is what `DELETE` means on every
|
|
2121
|
+
// removable thing here: a document is tombstoned, a key is revoked, and
|
|
2122
|
+
// a user keeps their row because the audit log names its id and
|
|
2123
|
+
// `grants.created_by` references it with no cascade. `PATCH` with
|
|
2124
|
+
// `disabled: false` is how somebody comes back.
|
|
2125
|
+
//
|
|
2126
|
+
// Through the same call `PATCH` makes rather than a second statement,
|
|
2127
|
+
// so the last-administrator guard covers both spellings. Two removals
|
|
2128
|
+
// with one check between them is how the guarded one gets routed
|
|
2129
|
+
// around.
|
|
2130
|
+
const disabled = await options.users.update(auth, id, { disabled: true });
|
|
2131
|
+
await options.audit.write({
|
|
2132
|
+
orgId: auth.orgId,
|
|
2133
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2134
|
+
action: 'disable_user',
|
|
2135
|
+
result: disabled === 'updated' ? 'allow' : 'deny',
|
|
2136
|
+
detail: { user_id: id, ...(disabled === 'updated' ? {} : { reason: disabled }) },
|
|
2137
|
+
requestId,
|
|
2138
|
+
});
|
|
2139
|
+
if (disabled === 'last-admin') {
|
|
2140
|
+
const problem = new Problem({
|
|
2141
|
+
type: 'https://nacre.work/errors/conflict',
|
|
2142
|
+
title: 'Conflict',
|
|
2143
|
+
status: 409,
|
|
2144
|
+
detail: 'This is the only active org_admin in the organization. Promote another user ' +
|
|
2145
|
+
'first — an organization with none has no route back through the API.',
|
|
2146
|
+
instance,
|
|
2147
|
+
requestId,
|
|
2148
|
+
});
|
|
2149
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
if (disabled === 'no-user') {
|
|
2153
|
+
const problem = notFound(instance, requestId);
|
|
2154
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
send(res, 204, null, requestId);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (req.method === 'PATCH') {
|
|
2161
|
+
const fields = (body ?? {});
|
|
2162
|
+
const wantsRole = 'role' in fields;
|
|
2163
|
+
const wantsDisabled = 'disabled' in fields;
|
|
2164
|
+
if (!wantsRole && !wantsDisabled) {
|
|
2165
|
+
const problem = badRequest(instance, requestId, "Give at least one of 'role' or 'disabled'.");
|
|
2166
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
if (wantsRole && fields.role !== 'member' && fields.role !== 'org_admin') {
|
|
2170
|
+
const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
|
|
2171
|
+
'installation rather than this organization and is not issued here.');
|
|
2172
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (wantsDisabled && typeof fields.disabled !== 'boolean') {
|
|
2176
|
+
const problem = badRequest(instance, requestId, "'disabled' must be a boolean.");
|
|
2177
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
// The last administrator cannot demote or disable themselves through
|
|
2181
|
+
// this path. Not paternalism: an organization with no `org_admin` has
|
|
2182
|
+
// no route back — every endpoint that could restore one is behind the
|
|
2183
|
+
// role that was just given up, and the remedy would be SQL. The check
|
|
2184
|
+
// is in the adapter, where it can count in the same transaction.
|
|
2185
|
+
const changed = await options.users.update(auth, id, {
|
|
2186
|
+
...(wantsRole ? { role: fields.role } : {}),
|
|
2187
|
+
...(wantsDisabled ? { disabled: fields.disabled } : {}),
|
|
2188
|
+
});
|
|
2189
|
+
await options.audit.write({
|
|
2190
|
+
orgId: auth.orgId,
|
|
2191
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2192
|
+
action: 'update_user',
|
|
2193
|
+
result: changed === 'updated' ? 'allow' : 'deny',
|
|
2194
|
+
detail: {
|
|
2195
|
+
user_id: id,
|
|
2196
|
+
...(wantsRole ? { role: fields.role } : {}),
|
|
2197
|
+
...(wantsDisabled ? { disabled: fields.disabled } : {}),
|
|
2198
|
+
...(changed === 'updated' ? {} : { reason: changed }),
|
|
2199
|
+
},
|
|
2200
|
+
requestId,
|
|
2201
|
+
});
|
|
2202
|
+
if (changed === 'last-admin') {
|
|
2203
|
+
// 409 rather than 404: the caller is looking straight at this user —
|
|
2204
|
+
// it is their own account or one they just listed — so the answer is
|
|
2205
|
+
// about the organization's state and not about what they can see.
|
|
2206
|
+
// Invariant 4 is about invisibility, and nothing here is invisible.
|
|
2207
|
+
const problem = new Problem({
|
|
2208
|
+
type: 'https://nacre.work/errors/conflict',
|
|
2209
|
+
title: 'Conflict',
|
|
2210
|
+
status: 409,
|
|
2211
|
+
detail: 'This is the only active org_admin in the organization. Promote another user ' +
|
|
2212
|
+
'first — an organization with none has no route back through the API.',
|
|
2213
|
+
instance,
|
|
2214
|
+
requestId,
|
|
2215
|
+
});
|
|
2216
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (changed === 'no-user') {
|
|
2220
|
+
const problem = notFound(instance, requestId);
|
|
2221
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
send(res, 204, null, requestId);
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
const passwordMatch = /^\/v1\/users\/([^/]+)\/password$/.exec(instance);
|
|
2229
|
+
if (req.method === 'POST' && passwordMatch && options.users !== undefined) {
|
|
2230
|
+
const id = decodeURIComponent(passwordMatch[1]);
|
|
2231
|
+
const password = await options.users.resetPassword(auth, id);
|
|
2232
|
+
await options.audit.write({
|
|
2233
|
+
orgId: auth.orgId,
|
|
2234
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2235
|
+
action: 'reset_password',
|
|
2236
|
+
result: password === undefined ? 'deny' : 'allow',
|
|
2237
|
+
// That it happened and to whom. Never the value.
|
|
2238
|
+
detail: { user_id: id },
|
|
2239
|
+
requestId,
|
|
2240
|
+
});
|
|
2241
|
+
if (password === undefined) {
|
|
2242
|
+
const problem = notFound(instance, requestId);
|
|
2243
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
// Generated rather than chosen, for the reason `init` gives: an argument
|
|
2247
|
+
// ends up in a shell history, and a password an administrator picked is
|
|
2248
|
+
// one they know. This is the only time it exists outside the process.
|
|
2249
|
+
send(res, 200, { password }, requestId);
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (instance === '/v1/groups' && options.groups !== undefined) {
|
|
2253
|
+
if (req.method === 'GET') {
|
|
2254
|
+
const page = readPage(url.searchParams, instance, requestId);
|
|
2255
|
+
if (page instanceof Problem) {
|
|
2256
|
+
send(res, page.status, page.toJSON(), requestId);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const { items, nextCursor } = await options.groups.list(auth, page);
|
|
2260
|
+
send(res, 200, { items: items.map(groupJson), next_cursor: nextCursor }, requestId);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
if (req.method === 'POST') {
|
|
2264
|
+
const name = (body ?? {}).name;
|
|
2265
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
2266
|
+
const problem = badRequest(instance, requestId, "'name' is required.");
|
|
2267
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
const created = await options.groups.create(auth, name.trim());
|
|
2271
|
+
if (created === undefined) {
|
|
2272
|
+
await options.audit.write({
|
|
2273
|
+
orgId: auth.orgId,
|
|
2274
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2275
|
+
action: 'create_group',
|
|
2276
|
+
result: 'deny',
|
|
2277
|
+
detail: { name: name.trim(), reason: 'name taken' },
|
|
2278
|
+
requestId,
|
|
2279
|
+
});
|
|
2280
|
+
const problem = new Problem({
|
|
2281
|
+
type: 'https://nacre.work/errors/conflict',
|
|
2282
|
+
title: 'Conflict',
|
|
2283
|
+
status: 409,
|
|
2284
|
+
detail: `A group named '${name.trim()}' already exists in this organization.`,
|
|
2285
|
+
instance,
|
|
2286
|
+
requestId,
|
|
2287
|
+
});
|
|
2288
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
await options.audit.write({
|
|
2292
|
+
orgId: auth.orgId,
|
|
2293
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2294
|
+
action: 'create_group',
|
|
2295
|
+
result: 'allow',
|
|
2296
|
+
detail: { group_id: created.id, name: created.name },
|
|
2297
|
+
requestId,
|
|
2298
|
+
});
|
|
2299
|
+
send(res, 201, groupJson(created), requestId);
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
const groupMatch = /^\/v1\/groups\/([^/]+)$/.exec(instance);
|
|
2304
|
+
if (req.method === 'DELETE' && groupMatch && options.groups !== undefined) {
|
|
2305
|
+
const id = decodeURIComponent(groupMatch[1]);
|
|
2306
|
+
const removed = await options.groups.remove(auth, id);
|
|
2307
|
+
await options.audit.write({
|
|
2308
|
+
orgId: auth.orgId,
|
|
2309
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2310
|
+
action: 'delete_group',
|
|
2311
|
+
result: removed ? 'allow' : 'deny',
|
|
2312
|
+
detail: { group_id: id },
|
|
2313
|
+
requestId,
|
|
2314
|
+
});
|
|
2315
|
+
if (!removed) {
|
|
2316
|
+
const problem = notFound(instance, requestId);
|
|
2317
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
send(res, 204, null, requestId);
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
const membersMatch = /^\/v1\/groups\/([^/]+)\/members$/.exec(instance);
|
|
2324
|
+
if (membersMatch && options.groups !== undefined) {
|
|
2325
|
+
const groupId = decodeURIComponent(membersMatch[1]);
|
|
2326
|
+
if (req.method === 'GET') {
|
|
2327
|
+
const page = readPage(url.searchParams, instance, requestId);
|
|
2328
|
+
if (page instanceof Problem) {
|
|
2329
|
+
send(res, page.status, page.toJSON(), requestId);
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
const result = await options.groups.members(auth, groupId, page);
|
|
2333
|
+
if (result === undefined) {
|
|
2334
|
+
const problem = notFound(instance, requestId);
|
|
2335
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
send(res, 200, { items: result.items.map(memberJson), next_cursor: result.nextCursor }, requestId);
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
if (req.method === 'POST') {
|
|
2342
|
+
const fields = (body ?? {});
|
|
2343
|
+
const type = fields.type;
|
|
2344
|
+
const memberId = fields.id;
|
|
2345
|
+
if ((type !== 'user' && type !== 'group') || typeof memberId !== 'string') {
|
|
2346
|
+
const problem = badRequest(instance, requestId, "'type' must be 'user' or 'group', and 'id' is required.");
|
|
2347
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
const outcome = await options.groups.addMember(auth, groupId, { type, id: memberId });
|
|
2351
|
+
await options.audit.write({
|
|
2352
|
+
orgId: auth.orgId,
|
|
2353
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2354
|
+
action: 'add_group_member',
|
|
2355
|
+
result: outcome === 'no-group' || outcome === 'no-member' ? 'deny' : 'allow',
|
|
2356
|
+
detail: {
|
|
2357
|
+
group_id: groupId,
|
|
2358
|
+
member_type: type,
|
|
2359
|
+
member_id: memberId,
|
|
2360
|
+
...(outcome === 'already' ? { note: 'already a member' } : {}),
|
|
2361
|
+
...(outcome === 'no-group' || outcome === 'no-member' ? { reason: outcome } : {}),
|
|
2362
|
+
},
|
|
2363
|
+
requestId,
|
|
2364
|
+
});
|
|
2365
|
+
if (outcome === 'no-group' || outcome === 'no-member') {
|
|
2366
|
+
const problem = notFound(instance, requestId);
|
|
2367
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
// 204 for both `added` and `already`. The request asked for a state and
|
|
2371
|
+
// that state holds either way, and distinguishing them would tell a
|
|
2372
|
+
// caller whether somebody was already in a group — which is a fact
|
|
2373
|
+
// about the group and not about their request.
|
|
2374
|
+
send(res, 204, null, requestId);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
// `{type}/{id}` rather than `{id}` alone: the edge is keyed by which member
|
|
2379
|
+
// column it uses, so a bare uuid does not identify one. Same shape `grants`
|
|
2380
|
+
// uses for the other end of the same relationship.
|
|
2381
|
+
const memberMatch = /^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/.exec(instance);
|
|
2382
|
+
if (req.method === 'DELETE' && memberMatch && options.groups !== undefined) {
|
|
2383
|
+
const groupId = decodeURIComponent(memberMatch[1]);
|
|
2384
|
+
const type = memberMatch[2];
|
|
2385
|
+
const memberId = decodeURIComponent(memberMatch[3]);
|
|
2386
|
+
const removed = await options.groups.removeMember(auth, groupId, { type, id: memberId });
|
|
2387
|
+
await options.audit.write({
|
|
2388
|
+
orgId: auth.orgId,
|
|
2389
|
+
actor: `${auth.principal.type}:${auth.principal.id}`,
|
|
2390
|
+
action: 'remove_group_member',
|
|
2391
|
+
result: removed ? 'allow' : 'deny',
|
|
2392
|
+
detail: { group_id: groupId, member_type: type, member_id: memberId },
|
|
2393
|
+
requestId,
|
|
2394
|
+
});
|
|
2395
|
+
if (!removed) {
|
|
2396
|
+
const problem = notFound(instance, requestId);
|
|
2397
|
+
send(res, problem.status, problem.toJSON(), requestId);
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
send(res, 204, null, requestId);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
1925
2403
|
const problem = notFound(instance, requestId);
|
|
1926
2404
|
send(res, problem.status, problem.toJSON(), requestId);
|
|
1927
2405
|
}
|