@sigrun/sigrun 0.2.1 → 0.3.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/CHANGELOG.md +50 -0
- package/README.md +234 -1
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +7 -1
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +86 -6
- package/dist/harness/context.js +25 -0
- package/dist/harness/event-bus.js +25 -14
- package/dist/harness/identity/accounts.js +3 -2
- package/dist/harness/identity/index.js +2 -1
- package/dist/harness/identity/invites.js +26 -9
- package/dist/harness/identity/link-sweep.js +47 -0
- package/dist/harness/identity/typed-username.js +15 -0
- package/dist/harness/identity/users.js +10 -7
- package/dist/harness/interactions.js +2 -0
- package/dist/harness/loader.js +57 -1
- package/dist/harness/migration-snapshot.js +97 -0
- package/dist/harness/reports.js +100 -0
- package/dist/harness/seam-host.js +11 -1
- package/dist/harness/settings.js +52 -4
- package/dist/jobs/docs.js +3 -0
- package/dist/jobs/fire.js +2 -0
- package/dist/jobs/parked.js +38 -0
- package/dist/jobs/task.js +37 -8
- package/dist/serve.js +23 -14
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +64 -48
- package/dist/server/auth/proxy-diagnostic.js +60 -0
- package/dist/server/auth/proxy-identity.js +134 -0
- package/dist/server/auth/proxy-only.js +28 -0
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/metrics.js +18 -2
- package/dist/server/routes/onboarding.js +97 -37
- package/dist/server/session-meta.js +1 -0
- package/dist/server/shutdown.js +93 -16
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
- package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +14 -12
- package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/dist/server/login/usernames.js +0 -15
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The C5 own-host rule (proxy-trust Task 4b): a peer that IS this host —
|
|
3
|
+
* loopback (127.0.0.0/8, ::1) or any current interface address — can
|
|
4
|
+
* reach the server without traversing the forward-auth proxy, because
|
|
5
|
+
* every local process shares those source addresses. Trusting such a
|
|
6
|
+
* peer through a CIDR RANGE would let any local process impersonate
|
|
7
|
+
* users, so an own-host peer vouches only when:
|
|
8
|
+
*
|
|
9
|
+
* 1. the shared secret is configured (`secret` + `secretHeader`) — the
|
|
10
|
+
* existing secret check then decides; or
|
|
11
|
+
* 2. that exact address is listed explicitly in `trustedProxies` (a
|
|
12
|
+
* bare address, or a /32 (IPv4) / /128 (IPv6) entry) — an explicit
|
|
13
|
+
* operator decision, which the boot warning surfaces.
|
|
14
|
+
*
|
|
15
|
+
* Otherwise both identity-trust paths (the request pipeline's proxy
|
|
16
|
+
* method and onboarding's `resolveProxyIdentity`) reject exactly the
|
|
17
|
+
* way an untrusted peer is rejected, with the OWN_HOST diagnostic.
|
|
18
|
+
*/
|
|
19
|
+
import { networkInterfaces } from "node:os";
|
|
20
|
+
import { isIP } from "node:net";
|
|
21
|
+
import { normalizePeerAddress } from "./ip.js";
|
|
22
|
+
/** Interface addresses refresh at most this often (milliseconds). */
|
|
23
|
+
const CACHE_TTL_MS = 30_000;
|
|
24
|
+
let testAddresses;
|
|
25
|
+
let cached;
|
|
26
|
+
/** The seam override, or the real interface list (cached ≤ 30 s). */
|
|
27
|
+
function ownHostAddresses() {
|
|
28
|
+
if (testAddresses !== undefined)
|
|
29
|
+
return testAddresses;
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (cached === undefined || now - cached.readAt >= CACHE_TTL_MS) {
|
|
32
|
+
const addresses = [];
|
|
33
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
34
|
+
for (const entry of entries ?? [])
|
|
35
|
+
addresses.push(entry.address);
|
|
36
|
+
}
|
|
37
|
+
cached = { addresses, readAt: now };
|
|
38
|
+
}
|
|
39
|
+
return cached.addresses;
|
|
40
|
+
}
|
|
41
|
+
function isLoopback(address) {
|
|
42
|
+
if (address === "::1")
|
|
43
|
+
return true;
|
|
44
|
+
if (isIP(address) !== 4)
|
|
45
|
+
return false;
|
|
46
|
+
return address.split(".")[0] === "127";
|
|
47
|
+
}
|
|
48
|
+
/** Loopback (127.0.0.0/8, ::1) or any current interface address; `::ffff:` unwrapped. */
|
|
49
|
+
export function isOwnHostAddress(address) {
|
|
50
|
+
if (address === undefined)
|
|
51
|
+
return false;
|
|
52
|
+
const candidate = normalizePeerAddress(address);
|
|
53
|
+
if (isLoopback(candidate))
|
|
54
|
+
return true;
|
|
55
|
+
return ownHostAddresses().some((entry) => normalizePeerAddress(entry) === candidate);
|
|
56
|
+
}
|
|
57
|
+
/** True when `address` is listed exactly in `cidrs`: a bare address, or a /32 (IPv4) or /128 (IPv6) entry for it. */
|
|
58
|
+
export function listedExactly(address, cidrs) {
|
|
59
|
+
const candidate = normalizePeerAddress(address);
|
|
60
|
+
for (const raw of cidrs) {
|
|
61
|
+
const entry = raw.trim();
|
|
62
|
+
if (entry === "")
|
|
63
|
+
continue;
|
|
64
|
+
const slash = entry.indexOf("/");
|
|
65
|
+
if (slash === -1) {
|
|
66
|
+
if (normalizePeerAddress(entry) === candidate)
|
|
67
|
+
return true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const base = normalizePeerAddress(entry.slice(0, slash));
|
|
71
|
+
if (base !== candidate)
|
|
72
|
+
continue;
|
|
73
|
+
const prefix = Number(entry.slice(slash + 1));
|
|
74
|
+
const family = isIP(candidate);
|
|
75
|
+
if (family === 4 && prefix === 32)
|
|
76
|
+
return true;
|
|
77
|
+
if (family === 6 && prefix === 128)
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/** The C5 rule: false only for an own-host peer reached through a range with no shared secret configured. */
|
|
83
|
+
export function ownHostPeerPermitted(address, trustedProxies, secretConfigured) {
|
|
84
|
+
if (!isOwnHostAddress(address))
|
|
85
|
+
return true;
|
|
86
|
+
if (secretConfigured)
|
|
87
|
+
return true;
|
|
88
|
+
return listedExactly(address, trustedProxies);
|
|
89
|
+
}
|
|
90
|
+
/** Test seam: replace the interface addresses (loopback stays implicit); `undefined` restores os.networkInterfaces(). */
|
|
91
|
+
export function setOwnHostAddressesForTests(addresses) {
|
|
92
|
+
testAddresses = addresses;
|
|
93
|
+
cached = undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Explicitly listed own-host entries (verbatim spellings), for the boot warning. */
|
|
96
|
+
export function listedOwnHostEntries(trustedProxies) {
|
|
97
|
+
return trustedProxies.filter((entry) => {
|
|
98
|
+
const trimmed = entry.trim();
|
|
99
|
+
if (trimmed === "")
|
|
100
|
+
return false;
|
|
101
|
+
const slash = trimmed.indexOf("/");
|
|
102
|
+
const base = slash === -1 ? trimmed : trimmed.slice(0, slash);
|
|
103
|
+
if (slash !== -1) {
|
|
104
|
+
const prefix = Number(trimmed.slice(slash + 1));
|
|
105
|
+
const family = isIP(normalizePeerAddress(base));
|
|
106
|
+
if (family === 4 && prefix !== 32)
|
|
107
|
+
return false;
|
|
108
|
+
if (family === 6 && prefix !== 128)
|
|
109
|
+
return false;
|
|
110
|
+
if (family === 0)
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return isOwnHostAddress(base);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -43,13 +43,19 @@
|
|
|
43
43
|
* the token's user must exist in the directory, else 401 (no log —
|
|
44
44
|
* token failures are not proxy diagnostics).
|
|
45
45
|
* 6. **Empty list** (the naive workstation path): the trimmed
|
|
46
|
-
* `X-Sigrun-User` header
|
|
46
|
+
* `X-Sigrun-User` header DERIVED to its canonical key (`admin: true`,
|
|
47
|
+
* method `"none"`). An unusable header (no derivable key) is a loud
|
|
48
|
+
* 400 `invalid_username`; an absent header behaves as before.
|
|
47
49
|
*/
|
|
48
50
|
import { timingSafeEqual } from "node:crypto";
|
|
49
51
|
import { ipAllowed } from "./ip.js";
|
|
50
52
|
import { isLoopbackHost } from "./loopback.js";
|
|
53
|
+
import { listedOwnHostEntries, ownHostPeerPermitted } from "./own-host.js";
|
|
54
|
+
import { ProxyRejectReason, warnProxyRejected } from "./proxy-diagnostic.js";
|
|
55
|
+
import { resolveProxyUser } from "./proxy-identity.js";
|
|
51
56
|
import { AdmissionRefusedError } from "../../harness/identity/users.js";
|
|
52
57
|
import { effectiveAdmin } from "../../harness/identity/groups.js";
|
|
58
|
+
import { deriveUsername } from "@sigrun/shared/usernames";
|
|
53
59
|
/** One header read: Fastify headers may be string | string[]; first wins. */
|
|
54
60
|
function headerOf(request, name) {
|
|
55
61
|
const value = request.headers[name.toLowerCase()];
|
|
@@ -190,22 +196,6 @@ function isPublicPath(request, ctx) {
|
|
|
190
196
|
}
|
|
191
197
|
return false;
|
|
192
198
|
}
|
|
193
|
-
/**
|
|
194
|
-
* The proxy-auth rejection diagnostic (release-readiness Task 1, spec §3):
|
|
195
|
-
* ONE `warn` per rejected request, naming the socket peer, the request
|
|
196
|
-
* method, a short reason, and — only when Fastify already resolved it —
|
|
197
|
-
* the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
|
|
198
|
-
* must never carry API tokens, cookies, identity-header VALUES, query
|
|
199
|
-
* strings, or the full request URL; a header NAME appears only where the
|
|
200
|
-
* reason itself is "that configured header was absent/wrong" (its value
|
|
201
|
-
* never does). This is diagnostic logging, not evidence that the
|
|
202
|
-
* configured allowlist is correct.
|
|
203
|
-
*/
|
|
204
|
-
function warnProxyRejected(request, reason) {
|
|
205
|
-
const peer = request.socket.remoteAddress ?? "unknown";
|
|
206
|
-
const route = request.routeOptions.url;
|
|
207
|
-
console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}`);
|
|
208
|
-
}
|
|
209
199
|
/**
|
|
210
200
|
* Stage 5a (proxy): resolve the forward-auth headers, or reply with the
|
|
211
201
|
* rejection. The secret header is DELETED from the request before any
|
|
@@ -229,7 +219,17 @@ async function resolveProxy(request, ctx) {
|
|
|
229
219
|
ctx.methods.includes("token");
|
|
230
220
|
if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
|
|
231
221
|
if (!attemptingToken) {
|
|
232
|
-
warnProxyRejected(request,
|
|
222
|
+
warnProxyRejected(request, ProxyRejectReason.UNTRUSTED);
|
|
223
|
+
}
|
|
224
|
+
return { status: 401 };
|
|
225
|
+
}
|
|
226
|
+
// C5: an own-host peer (loopback or a local interface address) can
|
|
227
|
+
// originate from ANY local process, so trust through a range alone
|
|
228
|
+
// never vouches — the shared secret must be configured (its check
|
|
229
|
+
// then decides), or the exact address must be listed explicitly.
|
|
230
|
+
if (!ownHostPeerPermitted(request.socket.remoteAddress, proxy.trustedProxies, proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
|
|
231
|
+
if (!attemptingToken) {
|
|
232
|
+
warnProxyRejected(request, ProxyRejectReason.OWN_HOST);
|
|
233
233
|
}
|
|
234
234
|
return { status: 401 };
|
|
235
235
|
}
|
|
@@ -239,7 +239,7 @@ async function resolveProxy(request, ctx) {
|
|
|
239
239
|
delete request.headers[name];
|
|
240
240
|
if (presented === undefined || !timingSafeStringEqual(presented, proxy.secret)) {
|
|
241
241
|
if (!attemptingToken) {
|
|
242
|
-
warnProxyRejected(request,
|
|
242
|
+
warnProxyRejected(request, ProxyRejectReason.secretReason(proxy.secretHeader));
|
|
243
243
|
}
|
|
244
244
|
return { status: 401 };
|
|
245
245
|
}
|
|
@@ -247,7 +247,7 @@ async function resolveProxy(request, ctx) {
|
|
|
247
247
|
const user = headerOf(request, proxy.userHeader);
|
|
248
248
|
if (user === undefined) {
|
|
249
249
|
if (!attemptingToken) {
|
|
250
|
-
warnProxyRejected(request,
|
|
250
|
+
warnProxyRejected(request, ProxyRejectReason.missingHeaderReason(proxy.userHeader));
|
|
251
251
|
}
|
|
252
252
|
return { status: 401 };
|
|
253
253
|
}
|
|
@@ -255,34 +255,29 @@ async function resolveProxy(request, ctx) {
|
|
|
255
255
|
?.split(",")
|
|
256
256
|
.map((group) => group.trim())
|
|
257
257
|
.filter((group) => group !== "") ?? [];
|
|
258
|
-
const { users } = ctx.opened.identity;
|
|
259
|
-
// The invalid-username case (a proxy header that can't be a directory
|
|
260
|
-
// user) is a loud 403, not a 500: the proxy vouched for an identity
|
|
261
|
-
// this server can never represent. The warn's reason never carries the
|
|
262
|
-
// claimed value (spec §3: no identity-header values).
|
|
263
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/.test(user)) {
|
|
264
|
-
warnProxyRejected(request, "unusable identity claim");
|
|
265
|
-
return { status: 403, error: "invalid_username" };
|
|
266
|
-
}
|
|
267
|
-
const existing = users.get(user);
|
|
268
|
-
if (existing === undefined && !proxy.autoProvision) {
|
|
269
|
-
warnProxyRejected(request, "untrusted identity source");
|
|
270
|
-
return { status: 403, error: "user_not_provisioned" };
|
|
271
|
-
}
|
|
258
|
+
const { users, accounts } = ctx.opened.identity;
|
|
272
259
|
let record;
|
|
273
260
|
try {
|
|
274
|
-
|
|
275
|
-
(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
261
|
+
const resolved = await resolveProxyUser({ users, accounts }, user, {
|
|
262
|
+
...(headerOf(request, proxy.nameHeader) === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: { name: headerOf(request, proxy.nameHeader) }),
|
|
265
|
+
...(headerOf(request, proxy.emailHeader) === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { email: headerOf(request, proxy.emailHeader) }),
|
|
268
|
+
}, { provision: proxy.autoProvision });
|
|
269
|
+
// The invalid-username case is a loud 403, not a 500: the proxy
|
|
270
|
+
// vouched for an identity this server can never represent. The warn
|
|
271
|
+
// never carries the claimed value (spec §3).
|
|
272
|
+
if (resolved.kind === "invalid") {
|
|
273
|
+
warnProxyRejected(request, ProxyRejectReason.UNUSABLE);
|
|
274
|
+
return { status: 403, error: "invalid_username" };
|
|
275
|
+
}
|
|
276
|
+
if (resolved.kind === "not_provisioned") {
|
|
277
|
+
warnProxyRejected(request, ProxyRejectReason.UNTRUSTED_IDENTITY_SOURCE);
|
|
278
|
+
return { status: 403, error: "user_not_provisioned" };
|
|
279
|
+
}
|
|
280
|
+
record = resolved.record;
|
|
286
281
|
}
|
|
287
282
|
catch (error) {
|
|
288
283
|
if (error instanceof AdmissionRefusedError) {
|
|
@@ -294,7 +289,7 @@ async function resolveProxy(request, ctx) {
|
|
|
294
289
|
// setGroups avoids the write when the values are unchanged. Effective
|
|
295
290
|
// admin is computed over the UPDATED record, so a group exit revokes
|
|
296
291
|
// the request's grant immediately and host-side checks agree.
|
|
297
|
-
record = await users.setGroups(user, "proxy", groups);
|
|
292
|
+
record = await users.setGroups(record.user, "proxy", groups);
|
|
298
293
|
return {
|
|
299
294
|
user: record.user,
|
|
300
295
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
@@ -332,6 +327,18 @@ export function resolveToken(request, ctx) {
|
|
|
332
327
|
}
|
|
333
328
|
/** The ONE request hook. Stages 1→5 in brief order; a stage replies or falls through. */
|
|
334
329
|
export function registerAuthPipeline(app, ctx) {
|
|
330
|
+
// C5 boot warning: an explicitly listed own-host trust entry WITHOUT
|
|
331
|
+
// a shared secret is the one shape where a local process can still
|
|
332
|
+
// impersonate users by connecting directly. Surface it once per
|
|
333
|
+
// entry, at registration (a boot-time config warning, not a request
|
|
334
|
+
// diagnostic — the throttled warnProxyRejected does not apply).
|
|
335
|
+
const { proxy } = ctx.config.auth;
|
|
336
|
+
if (ctx.methods.includes("proxy") &&
|
|
337
|
+
!(proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
|
|
338
|
+
for (const entry of listedOwnHostEntries(proxy.trustedProxies)) {
|
|
339
|
+
console.warn(`sigrun: trusting local address ${entry} without a shared secret lets any local process impersonate users; set server.auth.proxy.secretHeader`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
335
342
|
app.addHook("onRequest", async (request, reply) => {
|
|
336
343
|
// Stage 1: Host + Origin.
|
|
337
344
|
const hostStatus = checkHost(request, ctx);
|
|
@@ -378,8 +385,17 @@ export function registerAuthPipeline(app, ctx) {
|
|
|
378
385
|
// Stage 5/6: the configured methods, in order; the empty list is
|
|
379
386
|
// the naive loopback path.
|
|
380
387
|
if (ctx.methods.length === 0) {
|
|
388
|
+
const header = headerOf(request, "x-sigrun-user");
|
|
389
|
+
// The header is an unchecked (operator-typed) source: derive the
|
|
390
|
+
// canonical key on receipt, the same rule the CLI applies before
|
|
391
|
+
// sending. An unusable value is a loud 400, never a verbatim
|
|
392
|
+
// identity; an absent header keeps today's ownerless behavior.
|
|
393
|
+
const user = header === undefined ? undefined : deriveUsername(header);
|
|
394
|
+
if (header !== undefined && user === undefined) {
|
|
395
|
+
return await reply.code(400).send({ error: "invalid_username" });
|
|
396
|
+
}
|
|
381
397
|
request.principal = {
|
|
382
|
-
user
|
|
398
|
+
user,
|
|
383
399
|
admin: true,
|
|
384
400
|
service: false,
|
|
385
401
|
method: "none",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared proxy-auth rejection diagnostic (proxy-trust C1), extracted
|
|
3
|
+
* from the request pipeline so the onboarding routes can report WHY they
|
|
4
|
+
* rejected a proxy identity with the same line and redaction contract.
|
|
5
|
+
*
|
|
6
|
+
* ONE `warn` per rejected request, naming the socket peer, the request
|
|
7
|
+
* method, a short reason, and — only when Fastify already resolved it —
|
|
8
|
+
* the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
|
|
9
|
+
* must never carry API tokens, cookies, identity-header VALUES, query
|
|
10
|
+
* strings, or the full request URL; a header NAME appears only where the
|
|
11
|
+
* reason itself is "that configured header was absent/wrong" (its value
|
|
12
|
+
* never does). This is diagnostic logging, not evidence that the
|
|
13
|
+
* configured allowlist is correct.
|
|
14
|
+
*
|
|
15
|
+
* The throttle is per reason: one line per reason per 60 s, with a
|
|
16
|
+
* `(N suppressed)` suffix on the next emitted line for that reason —
|
|
17
|
+
* a flood of identical rejections cannot drown the log, and a DIFFERENT
|
|
18
|
+
* reason always emits immediately.
|
|
19
|
+
*/
|
|
20
|
+
/** One line per reason per this window; suppressed lines count up. */
|
|
21
|
+
const THROTTLE_MS = 60_000;
|
|
22
|
+
/** The reason vocabulary (byte-identical to the pipeline's originals). */
|
|
23
|
+
export const ProxyRejectReason = {
|
|
24
|
+
UNTRUSTED: "untrusted proxy",
|
|
25
|
+
UNUSABLE: "unusable identity claim",
|
|
26
|
+
UNTRUSTED_IDENTITY_SOURCE: "untrusted identity source",
|
|
27
|
+
OWN_HOST: "own-host peer without shared secret",
|
|
28
|
+
secretReason: (header) => `missing or wrong shared secret (${header} header)`,
|
|
29
|
+
missingHeaderReason: (header) => `missing identity header (${header})`,
|
|
30
|
+
};
|
|
31
|
+
const throttle = new Map();
|
|
32
|
+
let clock = () => Date.now();
|
|
33
|
+
/** Test seam: inject the clock the throttle reads. */
|
|
34
|
+
export function setProxyDiagnosticClock(now) {
|
|
35
|
+
clock = now;
|
|
36
|
+
}
|
|
37
|
+
/** Test seam: forget all throttle state (and restore the real clock). */
|
|
38
|
+
export function resetProxyDiagnostic() {
|
|
39
|
+
throttle.clear();
|
|
40
|
+
clock = () => Date.now();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Emit one sanitized rejection line, throttled per reason. The line
|
|
44
|
+
* carries no values by construction: it is built from the method, the
|
|
45
|
+
* socket peer, the caller-supplied reason (a fixed string, or one naming
|
|
46
|
+
* a configured header), and the route template.
|
|
47
|
+
*/
|
|
48
|
+
export function warnProxyRejected(request, reason) {
|
|
49
|
+
const now = clock();
|
|
50
|
+
const entry = throttle.get(reason);
|
|
51
|
+
if (entry !== undefined && now - entry.lastEmit < THROTTLE_MS) {
|
|
52
|
+
entry.suppressed += 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const suppressed = entry?.suppressed ?? 0;
|
|
56
|
+
throttle.set(reason, { lastEmit: now, suppressed: 0 });
|
|
57
|
+
const peer = request.socket.remoteAddress ?? "unknown";
|
|
58
|
+
const route = request.routeOptions.url;
|
|
59
|
+
console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}${suppressed > 0 ? ` (${suppressed} suppressed)` : ""}`);
|
|
60
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy identity resolution (username-derivation D3): a trusted proxy
|
|
3
|
+
* header names an external subject, not necessarily a directory key.
|
|
4
|
+
* Folded subjects are linked to users; case variants of an existing key
|
|
5
|
+
* adopt it, and every other new identity gets a derived, suffixed key.
|
|
6
|
+
*
|
|
7
|
+
* The request pipeline owns trust and diagnostics. The claim route owns
|
|
8
|
+
* its lock; only the pipeline entry below acquires `withClaimLock`.
|
|
9
|
+
*/
|
|
10
|
+
import { deriveUsername, foldSubject, isUsernameKey, withSuffix, } from "@sigrun/shared/usernames";
|
|
11
|
+
import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
|
|
12
|
+
import { withClaimLock } from "./claim-lock.js";
|
|
13
|
+
function identityParts(header) {
|
|
14
|
+
const subject = foldSubject(header);
|
|
15
|
+
const base = deriveUsername(header);
|
|
16
|
+
if (subject.length > 512 || base === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
return { subject, base };
|
|
19
|
+
}
|
|
20
|
+
function linkedUser(deps, subject) {
|
|
21
|
+
const link = deps.accounts.lookup("proxy", subject);
|
|
22
|
+
return link === undefined ? undefined : deps.users.get(link.user);
|
|
23
|
+
}
|
|
24
|
+
function isAddConflict(error) {
|
|
25
|
+
return error instanceof Error && /^user ".*" already exists$/.test(error.message);
|
|
26
|
+
}
|
|
27
|
+
/** Lock-free resolution body; conflicts become a signal for the pipeline's retry. */
|
|
28
|
+
async function provisionAttempt(deps, header, profile, opts) {
|
|
29
|
+
const parts = identityParts(header);
|
|
30
|
+
if (parts === undefined)
|
|
31
|
+
return { kind: "invalid" };
|
|
32
|
+
const linked = linkedUser(deps, parts.subject);
|
|
33
|
+
if (linked !== undefined)
|
|
34
|
+
return { kind: "user", record: linked };
|
|
35
|
+
if (isUsernameKey(parts.subject)) {
|
|
36
|
+
const existing = deps.users.get(parts.subject);
|
|
37
|
+
if (existing !== undefined) {
|
|
38
|
+
try {
|
|
39
|
+
await deps.accounts.link({
|
|
40
|
+
provider: "proxy",
|
|
41
|
+
subject: parts.subject,
|
|
42
|
+
user: existing.user,
|
|
43
|
+
via: "proxy",
|
|
44
|
+
});
|
|
45
|
+
return { kind: "user", record: existing };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof AccountLinkConflictError)
|
|
49
|
+
return { kind: "retry", error };
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!opts.provision)
|
|
55
|
+
return { kind: "not_provisioned" };
|
|
56
|
+
const key = withSuffix(parts.base, (candidate) => deps.users.get(candidate) !== undefined);
|
|
57
|
+
try {
|
|
58
|
+
const record = await deps.users.add({
|
|
59
|
+
user: key,
|
|
60
|
+
...(profile.name === undefined ? {} : { name: profile.name }),
|
|
61
|
+
...(profile.email === undefined ? {} : { email: profile.email }),
|
|
62
|
+
admin: opts.admin ?? false,
|
|
63
|
+
source: "proxy",
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
await deps.accounts.link({
|
|
67
|
+
provider: "proxy",
|
|
68
|
+
subject: parts.subject,
|
|
69
|
+
user: record.user,
|
|
70
|
+
via: "proxy",
|
|
71
|
+
});
|
|
72
|
+
return { kind: "user", record };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof AccountLinkConflictError) {
|
|
76
|
+
// Never leave the user created by this attempt attached to no
|
|
77
|
+
// identity when the link itself was refused.
|
|
78
|
+
await deps.users.remove(record.user);
|
|
79
|
+
return { kind: "retry", error };
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (isAddConflict(error))
|
|
86
|
+
return { kind: "retry", error: error };
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** One lookup/lock pass. A retry is observed only AFTER the lock returns. */
|
|
91
|
+
async function resolveOnce(deps, header, profile, opts) {
|
|
92
|
+
const parts = identityParts(header);
|
|
93
|
+
if (parts === undefined)
|
|
94
|
+
return { kind: "invalid" };
|
|
95
|
+
const linked = linkedUser(deps, parts.subject);
|
|
96
|
+
if (linked !== undefined)
|
|
97
|
+
return { kind: "user", record: linked };
|
|
98
|
+
return await withClaimLock(() => provisionAttempt(deps, header, profile, opts));
|
|
99
|
+
}
|
|
100
|
+
/** Pipeline entry: lookup outside the lock; on a miss, provision under it. */
|
|
101
|
+
export async function resolveProxyUser(deps, header, profile, opts) {
|
|
102
|
+
const first = await resolveOnce(deps, header, profile, opts);
|
|
103
|
+
if (first.kind !== "retry")
|
|
104
|
+
return first;
|
|
105
|
+
// A second-process conflict retries resolution once, after the claim
|
|
106
|
+
// section has returned. A second conflict is a real failure, not a loop.
|
|
107
|
+
const second = await resolveOnce(deps, header, profile, opts);
|
|
108
|
+
if (second.kind === "retry")
|
|
109
|
+
throw second.error;
|
|
110
|
+
return second;
|
|
111
|
+
}
|
|
112
|
+
/** Lock-free body. Callers MUST already hold `withClaimLock`. */
|
|
113
|
+
export async function provisionProxyUser(deps, header, profile, opts) {
|
|
114
|
+
const result = await provisionAttempt(deps, header, profile, opts);
|
|
115
|
+
if (result.kind === "retry")
|
|
116
|
+
throw result.error;
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
/** Read-only key computation for an already-vouched header. */
|
|
120
|
+
export function previewProxyUser(deps, header) {
|
|
121
|
+
const parts = identityParts(header);
|
|
122
|
+
if (parts === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
const linked = linkedUser(deps, parts.subject);
|
|
125
|
+
if (linked !== undefined)
|
|
126
|
+
return { user: linked.user, subject: header };
|
|
127
|
+
if (isUsernameKey(parts.subject) && deps.users.get(parts.subject) !== undefined) {
|
|
128
|
+
return { user: parts.subject, subject: header };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
user: withSuffix(parts.base, (candidate) => deps.users.get(candidate) !== undefined),
|
|
132
|
+
subject: header,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The C2 vouch rule for account-creating public paths (proxy-trust
|
|
3
|
+
* onboarding, spec Task 4): a proxy-only server bound beyond loopback
|
|
4
|
+
* admits accounts ONLY through a trusted proxy vouch. On such a boot
|
|
5
|
+
* every unvouched account-creating public path — the onboarding claim
|
|
6
|
+
* (setup-code and invite branches alike) and the login code-resume —
|
|
7
|
+
* refuses with 403 `proxy_identity_required`, and boot never mints or
|
|
8
|
+
* logs a setup code (it would be an unvouched account-creation path).
|
|
9
|
+
*
|
|
10
|
+
* The rule is deliberately about the CONFIGURED posture, not the
|
|
11
|
+
* request: the login code-resume has no proxy identity at all, so under
|
|
12
|
+
* `requiresProxyVouch` it always refuses. Mixed `[proxy, token]`
|
|
13
|
+
* deployments keep the setup-code/invite paths (token callers exist),
|
|
14
|
+
* and loopback binds keep them too (loopback is its own trust).
|
|
15
|
+
*/
|
|
16
|
+
import { isLoopbackHost } from "./loopback.js";
|
|
17
|
+
/** Whether the configured auth methods are exactly proxy-only. */
|
|
18
|
+
export function isProxyOnly(methods) {
|
|
19
|
+
return methods.includes("proxy") && !methods.includes("token");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether this boot requires a proxy vouch for account creation:
|
|
23
|
+
* proxy-only AND bound beyond loopback. The loopback test is the shared
|
|
24
|
+
* one (`127.0.0.0/8`, `::1`, `localhost`; `::ffff:127.*` unwrapped).
|
|
25
|
+
*/
|
|
26
|
+
export function requiresProxyVouch(methods, listenHost) {
|
|
27
|
+
return isProxyOnly(methods) && !isLoopbackHost(listenHost);
|
|
28
|
+
}
|
|
@@ -1,9 +1,85 @@
|
|
|
1
|
+
import { deriveUsername, withSuffix } from "@sigrun/shared/usernames";
|
|
1
2
|
import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
|
|
2
3
|
import { AdmissionRefusedError, } from "../../harness/identity/users.js";
|
|
3
4
|
import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
|
|
4
5
|
import { hashToken } from "../../harness/identity/tokens.js";
|
|
5
6
|
import { withClaimLock } from "../auth/claim-lock.js";
|
|
6
|
-
|
|
7
|
+
/** The first usable profile claim wins; the external subject remains exact. */
|
|
8
|
+
export function loginUsernameCandidate(profile, subject) {
|
|
9
|
+
for (const candidate of [profile.username, profile.email, subject]) {
|
|
10
|
+
if (candidate === undefined)
|
|
11
|
+
continue;
|
|
12
|
+
const derived = deriveUsername(candidate);
|
|
13
|
+
if (derived !== undefined)
|
|
14
|
+
return derived;
|
|
15
|
+
}
|
|
16
|
+
return "user";
|
|
17
|
+
}
|
|
18
|
+
function isAddConflict(error) {
|
|
19
|
+
return error instanceof Error && /^user ".*" already exists$/.test(error.message);
|
|
20
|
+
}
|
|
21
|
+
function isLinkConflict(error) {
|
|
22
|
+
return error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:"));
|
|
23
|
+
}
|
|
24
|
+
async function sessionForLinkedUser(input, deps) {
|
|
25
|
+
const existing = deps.accounts.lookup(input.provider, input.result.subject);
|
|
26
|
+
if (deps.users.get(existing.user) === undefined) {
|
|
27
|
+
return { kind: "refused", error: "not_allowed" };
|
|
28
|
+
}
|
|
29
|
+
await deps.users.setGroups(existing.user, input.provider, input.result.groups ?? []);
|
|
30
|
+
return { kind: "session", user: existing.user };
|
|
31
|
+
}
|
|
32
|
+
/** Lock-free provisioning body; add/link conflicts become retry signals. */
|
|
33
|
+
async function provisionLogin(input, deps) {
|
|
34
|
+
if (deps.accounts.lookup(input.provider, input.result.subject) !== undefined) {
|
|
35
|
+
return sessionForLinkedUser(input, deps);
|
|
36
|
+
}
|
|
37
|
+
// Admission is checked before redemption so an M1 refusal burns no invite.
|
|
38
|
+
deps.users.checkAdmission();
|
|
39
|
+
const base = loginUsernameCandidate(input.result.profile, input.result.subject);
|
|
40
|
+
const autoProvision = deps.policy.autoProvision === true;
|
|
41
|
+
const user = autoProvision
|
|
42
|
+
? withSuffix(base, (name) => deps.users.get(name) !== undefined)
|
|
43
|
+
: base;
|
|
44
|
+
if (!autoProvision && deps.users.get(user) !== undefined) {
|
|
45
|
+
return { kind: "refused", error: "not_allowed" };
|
|
46
|
+
}
|
|
47
|
+
const grant = autoProvision
|
|
48
|
+
? { admin: false }
|
|
49
|
+
: await deps.invites.redeem(input.code, user);
|
|
50
|
+
if (grant === undefined)
|
|
51
|
+
return { kind: "refused", error: "not_allowed" };
|
|
52
|
+
try {
|
|
53
|
+
const record = await deps.users.add({
|
|
54
|
+
user,
|
|
55
|
+
...profileFields(input.result.profile),
|
|
56
|
+
admin: grant.admin,
|
|
57
|
+
source: "login",
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
await deps.accounts.link({
|
|
61
|
+
provider: input.provider,
|
|
62
|
+
subject: input.result.subject,
|
|
63
|
+
user,
|
|
64
|
+
via: "login",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (isLinkConflict(error)) {
|
|
69
|
+
await deps.users.remove(record.user);
|
|
70
|
+
return { kind: "retry", error: error };
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
await deps.users.setGroups(user, input.provider, input.result.groups ?? []);
|
|
75
|
+
return { kind: "session", user: record.user };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (isAddConflict(error))
|
|
79
|
+
return { kind: "retry", error: error };
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
7
83
|
function allowed(result, policy) {
|
|
8
84
|
if (policy.allowedGroups !== undefined &&
|
|
9
85
|
!policy.allowedGroups.some((group) => result.groups?.includes(group) === true))
|
|
@@ -42,13 +118,8 @@ export async function resolveLogin(input, deps) {
|
|
|
42
118
|
return { kind: "linked", user: input.linkUser };
|
|
43
119
|
}
|
|
44
120
|
const existing = deps.accounts.lookup(provider, result.subject);
|
|
45
|
-
if (existing !== undefined)
|
|
46
|
-
|
|
47
|
-
return { kind: "refused", error: "not_allowed" };
|
|
48
|
-
}
|
|
49
|
-
await deps.users.setGroups(existing.user, provider, result.groups ?? []);
|
|
50
|
-
return { kind: "session", user: existing.user };
|
|
51
|
-
}
|
|
121
|
+
if (existing !== undefined)
|
|
122
|
+
return sessionForLinkedUser(input, deps);
|
|
52
123
|
const setupResolution = await withClaimLock(async () => {
|
|
53
124
|
const validSetupCode = input.code !== undefined && setupCodeMatches(input.code, deps.setupCode());
|
|
54
125
|
if (deps.users.isClaimed()) {
|
|
@@ -69,7 +140,7 @@ export async function resolveLogin(input, deps) {
|
|
|
69
140
|
if (!validSetupCode)
|
|
70
141
|
return { kind: "needs_code", reason: "setup" };
|
|
71
142
|
const claim = deps.setupClaims.matches(provider, result.subject, input.code);
|
|
72
|
-
const user = claim?.user ??
|
|
143
|
+
const user = claim?.user ?? withSuffix(loginUsernameCandidate(result.profile, result.subject), (name) => deps.users.get(name) !== undefined);
|
|
73
144
|
// Persist the recovery breadcrumb before creating the user. On a
|
|
74
145
|
// retry, only this exact subject may finish the claim; if the user
|
|
75
146
|
// write did not happen, it is retried under the recorded name.
|
|
@@ -99,23 +170,9 @@ export async function resolveLogin(input, deps) {
|
|
|
99
170
|
if (deps.policy.autoProvision !== true && input.code === undefined) {
|
|
100
171
|
return { kind: "needs_code", reason: "invite" };
|
|
101
172
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const grant = deps.policy.autoProvision === true
|
|
106
|
-
? { admin: false }
|
|
107
|
-
: await deps.invites.redeem(input.code, user);
|
|
108
|
-
if (grant === undefined)
|
|
109
|
-
return { kind: "refused", error: "not_allowed" };
|
|
110
|
-
const record = await deps.users.add({
|
|
111
|
-
user,
|
|
112
|
-
...profileFields(result.profile),
|
|
113
|
-
admin: grant.admin,
|
|
114
|
-
source: "login",
|
|
115
|
-
});
|
|
116
|
-
await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
|
|
117
|
-
await deps.users.setGroups(user, provider, result.groups ?? []);
|
|
118
|
-
return { kind: "session", user: record.user };
|
|
173
|
+
const provisioned = await withClaimLock(() => provisionLogin(input, deps));
|
|
174
|
+
if (provisioned.kind !== "retry")
|
|
175
|
+
return provisioned;
|
|
119
176
|
}
|
|
120
177
|
catch (error) {
|
|
121
178
|
if (error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:")))
|
|
@@ -130,4 +187,9 @@ export async function resolveLogin(input, deps) {
|
|
|
130
187
|
}
|
|
131
188
|
throw error;
|
|
132
189
|
}
|
|
190
|
+
// Conflicts from another writer retry once, only after the section returns.
|
|
191
|
+
const second = await withClaimLock(() => provisionLogin(input, deps));
|
|
192
|
+
if (second.kind === "retry")
|
|
193
|
+
throw second.error;
|
|
194
|
+
return second;
|
|
133
195
|
}
|