@thotischner/observability-mcp 3.0.0 → 3.1.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/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/policy/batch-dry-run.js +15 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +522 -67
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +898 -116
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { validatePassword, passwordPolicyFromEnv, passwordPolicyDisabledFromEnv, DEFAULT_PASSWORD_POLICY, COMMON_PASSWORD_DENYLIST, } from "./password-policy.js";
|
|
7
|
+
test("a strong password passes the default policy", () => {
|
|
8
|
+
const r = validatePassword("Tr0ub4dour&3xtra");
|
|
9
|
+
assert.equal(r.ok, true);
|
|
10
|
+
assert.deepEqual(r.errors, []);
|
|
11
|
+
});
|
|
12
|
+
test("too-short password is rejected with a length error", () => {
|
|
13
|
+
const r = validatePassword("Ab1!xy");
|
|
14
|
+
assert.equal(r.ok, false);
|
|
15
|
+
assert.ok(r.errors.some((e) => e.includes("at least 12")));
|
|
16
|
+
});
|
|
17
|
+
test("insufficient character classes is rejected", () => {
|
|
18
|
+
// 16 lowercase letters — long enough, but only one class.
|
|
19
|
+
const r = validatePassword("abcdefghijklmnop");
|
|
20
|
+
assert.equal(r.ok, false);
|
|
21
|
+
assert.ok(r.errors.some((e) => e.includes("at least 3 of")));
|
|
22
|
+
});
|
|
23
|
+
test("two classes still fails the default min of three", () => {
|
|
24
|
+
const r = validatePassword("abcdefghijkl1234"); // lower + digit only
|
|
25
|
+
assert.equal(r.ok, false);
|
|
26
|
+
assert.ok(r.errors.some((e) => e.includes("at least 3 of")));
|
|
27
|
+
});
|
|
28
|
+
test("exactly three classes passes", () => {
|
|
29
|
+
const r = validatePassword("abcdefghABCD1234"); // lower+upper+digit = 3
|
|
30
|
+
assert.equal(r.ok, true);
|
|
31
|
+
});
|
|
32
|
+
test("common-password denylist is enforced case-insensitively", () => {
|
|
33
|
+
// Permissive length/classes so ONLY the denylist can reject — the
|
|
34
|
+
// denylist matches the whole password exactly (case-insensitively).
|
|
35
|
+
const permissive = { minLength: 1, maxLength: 1024, minClasses: 1, denylistEnabled: true };
|
|
36
|
+
const r = validatePassword("PaSsWoRd123", permissive);
|
|
37
|
+
assert.equal(r.ok, false);
|
|
38
|
+
assert.ok(r.errors.some((e) => e.includes("denylist")));
|
|
39
|
+
});
|
|
40
|
+
test("denylist entries themselves are all rejected when long enough is waived", () => {
|
|
41
|
+
// Sanity: the canonical app-specific entries are present.
|
|
42
|
+
assert.ok(COMMON_PASSWORD_DENYLIST.has("observability-mcp"));
|
|
43
|
+
assert.ok(COMMON_PASSWORD_DENYLIST.has("prometheus"));
|
|
44
|
+
});
|
|
45
|
+
test("password containing the username is rejected", () => {
|
|
46
|
+
const r = validatePassword("alice-Secret-99x", DEFAULT_PASSWORD_POLICY, "alice");
|
|
47
|
+
assert.equal(r.ok, false);
|
|
48
|
+
assert.ok(r.errors.some((e) => e.includes("username")));
|
|
49
|
+
});
|
|
50
|
+
test("short usernames (<3 chars) do not trigger the username check", () => {
|
|
51
|
+
// "al" is too short to meaningfully match; password is otherwise strong.
|
|
52
|
+
const r = validatePassword("alXyZ12345678!", DEFAULT_PASSWORD_POLICY, "al");
|
|
53
|
+
assert.equal(r.ok, true);
|
|
54
|
+
});
|
|
55
|
+
test("code-point length counts multibyte characters as one", () => {
|
|
56
|
+
// 11 emoji = 11 code points < 12 → too short despite a large byte length.
|
|
57
|
+
const eleven = "😀".repeat(11);
|
|
58
|
+
assert.equal(validatePassword(eleven).ok, false);
|
|
59
|
+
// 12 of them clears length; emoji count as the "symbol" class only → 1 class.
|
|
60
|
+
const twelve = "😀".repeat(12);
|
|
61
|
+
const r = validatePassword(twelve);
|
|
62
|
+
assert.equal(r.ok, false); // fails on classes, not length
|
|
63
|
+
assert.ok(r.errors.some((e) => e.includes("at least 3 of")));
|
|
64
|
+
assert.ok(!r.errors.some((e) => e.includes("at least 12")));
|
|
65
|
+
});
|
|
66
|
+
test("over-long password is rejected (scrypt DoS guard)", () => {
|
|
67
|
+
const r = validatePassword("Aa1!".repeat(300)); // 1200 chars > 1024
|
|
68
|
+
assert.equal(r.ok, false);
|
|
69
|
+
assert.ok(r.errors.some((e) => e.includes("at most")));
|
|
70
|
+
});
|
|
71
|
+
test("disabling the denylist lets a denylisted password through", () => {
|
|
72
|
+
const permissive = { minLength: 1, maxLength: 1024, minClasses: 1, denylistEnabled: false };
|
|
73
|
+
// 'password123' is on the denylist; with it disabled only length/classes
|
|
74
|
+
// apply, which this permissive policy waives.
|
|
75
|
+
assert.equal(validatePassword("password123", permissive).ok, true);
|
|
76
|
+
});
|
|
77
|
+
test("passwordPolicyFromEnv parses overrides and ignores bad input", () => {
|
|
78
|
+
const p = passwordPolicyFromEnv({
|
|
79
|
+
OMCP_PASSWORD_MIN_LENGTH: "16",
|
|
80
|
+
OMCP_PASSWORD_MIN_CLASSES: "bad",
|
|
81
|
+
OMCP_PASSWORD_DENYLIST_DISABLED: "true",
|
|
82
|
+
});
|
|
83
|
+
assert.equal(p.minLength, 16);
|
|
84
|
+
assert.equal(p.minClasses, DEFAULT_PASSWORD_POLICY.minClasses); // bad → default
|
|
85
|
+
assert.equal(p.denylistEnabled, false);
|
|
86
|
+
});
|
|
87
|
+
test("passwordPolicyDisabledFromEnv recognises truthy values", () => {
|
|
88
|
+
assert.equal(passwordPolicyDisabledFromEnv({ OMCP_PASSWORD_POLICY_DISABLED: "1" }), true);
|
|
89
|
+
assert.equal(passwordPolicyDisabledFromEnv({ OMCP_PASSWORD_POLICY_DISABLED: "false" }), false);
|
|
90
|
+
assert.equal(passwordPolicyDisabledFromEnv({}), false);
|
|
91
|
+
});
|
|
92
|
+
test("CLI hash-password.mjs stays in sync with the canonical policy", () => {
|
|
93
|
+
// The CLI deliberately duplicates the policy to stay dependency-free
|
|
94
|
+
// (same precedent as the scrypt params). This guard fails loudly if the
|
|
95
|
+
// two drift: every canonical denylist entry + the defaults must appear
|
|
96
|
+
// verbatim in the script. (__dirname → mcp-server/src/auth)
|
|
97
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
98
|
+
const cliPath = join(here, "..", "..", "..", "scripts", "hash-password.mjs");
|
|
99
|
+
const cli = readFileSync(cliPath, "utf8");
|
|
100
|
+
for (const entry of COMMON_PASSWORD_DENYLIST) {
|
|
101
|
+
assert.ok(cli.includes(`"${entry}"`), `CLI denylist is missing "${entry}"`);
|
|
102
|
+
}
|
|
103
|
+
assert.ok(cli.includes(`"OMCP_PASSWORD_MIN_LENGTH", ${DEFAULT_PASSWORD_POLICY.minLength}`), "CLI minLength default drifted");
|
|
104
|
+
assert.ok(cli.includes(`"OMCP_PASSWORD_MIN_CLASSES", ${DEFAULT_PASSWORD_POLICY.minClasses}`), "CLI minClasses default drifted");
|
|
105
|
+
assert.ok(cli.includes(`PW_MAX_LENGTH = ${DEFAULT_PASSWORD_POLICY.maxLength}`), "CLI maxLength default drifted");
|
|
106
|
+
});
|
|
107
|
+
test("multiple violations are all reported", () => {
|
|
108
|
+
const r = validatePassword("admin"); // short, 1 class, on denylist
|
|
109
|
+
assert.equal(r.ok, false);
|
|
110
|
+
assert.ok(r.errors.length >= 2);
|
|
111
|
+
});
|
|
@@ -63,14 +63,29 @@ export async function evaluateBatch(engine, req, validResources, validActions, l
|
|
|
63
63
|
dropped.push({ kind: "cap", value: `actions=${actions.length}`, reason: `truncated to ${limits.maxActions} (cap)` });
|
|
64
64
|
actions.length = limits.maxActions;
|
|
65
65
|
}
|
|
66
|
+
// Reject keys that could mutate Object.prototype if injected from
|
|
67
|
+
// user input — `__proto__`, `constructor`, `prototype`. CodeQL
|
|
68
|
+
// js/prototype-polluting-assignment + js/remote-property-injection.
|
|
69
|
+
const DANGEROUS = new Set(["__proto__", "constructor", "prototype"]);
|
|
70
|
+
const isSafeKey = (k) => !DANGEROUS.has(k);
|
|
71
|
+
// Plain object so JSON round-trip + deep-equal in tests keeps
|
|
72
|
+
// working; the DANGEROUS guard below is the actual protection.
|
|
66
73
|
const matrix = {};
|
|
67
74
|
let allowCount = 0;
|
|
68
75
|
let denyCount = 0;
|
|
69
76
|
for (const s of subjects) {
|
|
77
|
+
if (!isSafeKey(s.key)) {
|
|
78
|
+
dropped.push({ kind: "subject", value: s.key, reason: "reserved key name" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
70
81
|
matrix[s.key] = {};
|
|
71
82
|
for (const r of resources) {
|
|
83
|
+
if (!isSafeKey(String(r)))
|
|
84
|
+
continue;
|
|
72
85
|
matrix[s.key][r] = {};
|
|
73
86
|
for (const a of actions) {
|
|
87
|
+
if (!isSafeKey(String(a)))
|
|
88
|
+
continue;
|
|
74
89
|
const verdict = await Promise.resolve(engine.evaluate(s.roles, r, a, s.tenant ? { tenant: s.tenant } : undefined));
|
|
75
90
|
matrix[s.key][r][a] = {
|
|
76
91
|
allowed: verdict.allowed,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session/JWT revocation blocklist for the management plane.
|
|
3
|
+
*
|
|
4
|
+
* OMCP sessions are stateless signed cookies (see ./session.ts) — there is
|
|
5
|
+
* no server-side session table to delete from, so a logout or a
|
|
6
|
+
* compromised-credential incident can't, on its own, invalidate an
|
|
7
|
+
* outstanding cookie before its `exp`. This blocklist closes that gap: a
|
|
8
|
+
* small append-only JSONL file of revocations that every request consults
|
|
9
|
+
* (in buildSessionAttacher) before a session payload is trusted.
|
|
10
|
+
*
|
|
11
|
+
* Two revocation shapes:
|
|
12
|
+
* - session: drop one specific session by its `sid`.
|
|
13
|
+
* - subject: drop every session for a `sub` issued at or before the
|
|
14
|
+
* revocation timestamp ("force re-login"). A fresh login afterwards
|
|
15
|
+
* mints a new session with a later `iat`, which is NOT caught — so
|
|
16
|
+
* subject-revoke is a logout-everywhere, not a permanent ban.
|
|
17
|
+
*
|
|
18
|
+
* Backend is an on-disk JSONL file (one entry per line, mode 0600). When
|
|
19
|
+
* no path is configured the store is in-memory only (lost on restart);
|
|
20
|
+
* set OMCP_AUTH_REVOCATION_FILE so revocations survive a restart.
|
|
21
|
+
*
|
|
22
|
+
* Multi-replica caveat: the file is read once at startup and the writing
|
|
23
|
+
* replica updates its own in-memory index immediately, but there is no
|
|
24
|
+
* live cross-replica propagation — a revocation issued on replica A is
|
|
25
|
+
* not seen by replica B until B restarts. A shared-store backend (Redis,
|
|
26
|
+
* mirroring the SCIM / transport stores) is the planned path to
|
|
27
|
+
* fleet-wide live propagation; see docs/access-control.md.
|
|
28
|
+
*/
|
|
29
|
+
export type RevocationKind = "session" | "subject";
|
|
30
|
+
export interface RevocationEntry {
|
|
31
|
+
kind: RevocationKind;
|
|
32
|
+
/** sid for kind "session"; sub for kind "subject". */
|
|
33
|
+
value: string;
|
|
34
|
+
/** Revocation time, seconds since epoch. */
|
|
35
|
+
revokedAt: number;
|
|
36
|
+
/** Optional free-text reason (truncated by the caller). */
|
|
37
|
+
reason?: string;
|
|
38
|
+
/** Optional actor who issued the revocation (admin sub). */
|
|
39
|
+
by?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RevocationStoreConfig {
|
|
42
|
+
/** JSONL file path. Omitted → in-memory only. */
|
|
43
|
+
path?: string;
|
|
44
|
+
/** Clock injection for tests. Returns seconds since epoch. */
|
|
45
|
+
now?: () => number;
|
|
46
|
+
}
|
|
47
|
+
/** The minimal session shape isRevoked needs to make a decision. */
|
|
48
|
+
export interface RevocableSession {
|
|
49
|
+
sub: string;
|
|
50
|
+
iat: number;
|
|
51
|
+
sid?: string;
|
|
52
|
+
}
|
|
53
|
+
export declare class RevocationStore {
|
|
54
|
+
/** Revoked individual session ids. */
|
|
55
|
+
private readonly sids;
|
|
56
|
+
/** sub → latest subject-revocation cutoff (seconds since epoch). */
|
|
57
|
+
private readonly subjectCutoffs;
|
|
58
|
+
/** Full ordered history, kept so list() can mirror the file. */
|
|
59
|
+
private readonly entries;
|
|
60
|
+
private readonly path?;
|
|
61
|
+
private readonly now;
|
|
62
|
+
/** Serialises appends so two concurrent revokes can't interleave a line. */
|
|
63
|
+
private writeChain;
|
|
64
|
+
private constructor();
|
|
65
|
+
/** Build a store, loading any existing on-disk blocklist. */
|
|
66
|
+
static create(cfg?: RevocationStoreConfig): Promise<RevocationStore>;
|
|
67
|
+
/** True when this store persists to disk. */
|
|
68
|
+
get persistent(): boolean;
|
|
69
|
+
get filePath(): string | undefined;
|
|
70
|
+
/** Number of revocation entries currently held. */
|
|
71
|
+
get size(): number;
|
|
72
|
+
private load;
|
|
73
|
+
/** Apply an entry to the in-memory indices + history (no write). */
|
|
74
|
+
private index;
|
|
75
|
+
private persist;
|
|
76
|
+
/** Revoke one session by its sid. Idempotent. */
|
|
77
|
+
revokeSession(sid: string, opts?: {
|
|
78
|
+
reason?: string;
|
|
79
|
+
by?: string;
|
|
80
|
+
}): Promise<RevocationEntry>;
|
|
81
|
+
/** Revoke every session for a subject issued at or before now. */
|
|
82
|
+
revokeSubject(sub: string, opts?: {
|
|
83
|
+
reason?: string;
|
|
84
|
+
by?: string;
|
|
85
|
+
}): Promise<RevocationEntry>;
|
|
86
|
+
/**
|
|
87
|
+
* Decide whether a session is revoked. Pure + synchronous so it can run
|
|
88
|
+
* on the hot path of every request without an await.
|
|
89
|
+
*/
|
|
90
|
+
isRevoked(session: RevocableSession): boolean;
|
|
91
|
+
/** Snapshot of all entries, newest last (file order). */
|
|
92
|
+
list(): RevocationEntry[];
|
|
93
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session/JWT revocation blocklist for the management plane.
|
|
3
|
+
*
|
|
4
|
+
* OMCP sessions are stateless signed cookies (see ./session.ts) — there is
|
|
5
|
+
* no server-side session table to delete from, so a logout or a
|
|
6
|
+
* compromised-credential incident can't, on its own, invalidate an
|
|
7
|
+
* outstanding cookie before its `exp`. This blocklist closes that gap: a
|
|
8
|
+
* small append-only JSONL file of revocations that every request consults
|
|
9
|
+
* (in buildSessionAttacher) before a session payload is trusted.
|
|
10
|
+
*
|
|
11
|
+
* Two revocation shapes:
|
|
12
|
+
* - session: drop one specific session by its `sid`.
|
|
13
|
+
* - subject: drop every session for a `sub` issued at or before the
|
|
14
|
+
* revocation timestamp ("force re-login"). A fresh login afterwards
|
|
15
|
+
* mints a new session with a later `iat`, which is NOT caught — so
|
|
16
|
+
* subject-revoke is a logout-everywhere, not a permanent ban.
|
|
17
|
+
*
|
|
18
|
+
* Backend is an on-disk JSONL file (one entry per line, mode 0600). When
|
|
19
|
+
* no path is configured the store is in-memory only (lost on restart);
|
|
20
|
+
* set OMCP_AUTH_REVOCATION_FILE so revocations survive a restart.
|
|
21
|
+
*
|
|
22
|
+
* Multi-replica caveat: the file is read once at startup and the writing
|
|
23
|
+
* replica updates its own in-memory index immediately, but there is no
|
|
24
|
+
* live cross-replica propagation — a revocation issued on replica A is
|
|
25
|
+
* not seen by replica B until B restarts. A shared-store backend (Redis,
|
|
26
|
+
* mirroring the SCIM / transport stores) is the planned path to
|
|
27
|
+
* fleet-wide live propagation; see docs/access-control.md.
|
|
28
|
+
*/
|
|
29
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
function defaultNow() {
|
|
32
|
+
return Math.floor(Date.now() / 1000);
|
|
33
|
+
}
|
|
34
|
+
export class RevocationStore {
|
|
35
|
+
/** Revoked individual session ids. */
|
|
36
|
+
sids = new Set();
|
|
37
|
+
/** sub → latest subject-revocation cutoff (seconds since epoch). */
|
|
38
|
+
subjectCutoffs = new Map();
|
|
39
|
+
/** Full ordered history, kept so list() can mirror the file. */
|
|
40
|
+
entries = [];
|
|
41
|
+
path;
|
|
42
|
+
now;
|
|
43
|
+
/** Serialises appends so two concurrent revokes can't interleave a line. */
|
|
44
|
+
writeChain = Promise.resolve();
|
|
45
|
+
constructor(cfg) {
|
|
46
|
+
this.path = cfg.path;
|
|
47
|
+
this.now = cfg.now ?? defaultNow;
|
|
48
|
+
}
|
|
49
|
+
/** Build a store, loading any existing on-disk blocklist. */
|
|
50
|
+
static async create(cfg = {}) {
|
|
51
|
+
const store = new RevocationStore(cfg);
|
|
52
|
+
await store.load();
|
|
53
|
+
return store;
|
|
54
|
+
}
|
|
55
|
+
/** True when this store persists to disk. */
|
|
56
|
+
get persistent() {
|
|
57
|
+
return !!this.path;
|
|
58
|
+
}
|
|
59
|
+
get filePath() {
|
|
60
|
+
return this.path;
|
|
61
|
+
}
|
|
62
|
+
/** Number of revocation entries currently held. */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.entries.length;
|
|
65
|
+
}
|
|
66
|
+
async load() {
|
|
67
|
+
if (!this.path)
|
|
68
|
+
return;
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = await readFile(this.path, "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
// ENOENT is expected on first boot — nothing to load.
|
|
75
|
+
if (err.code === "ENOENT")
|
|
76
|
+
return;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
for (const line of raw.split("\n")) {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
continue;
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(trimmed);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// A torn / hand-edited line shouldn't take the whole store down.
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const entry = coerceEntry(parsed);
|
|
92
|
+
if (entry)
|
|
93
|
+
this.index(entry);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Apply an entry to the in-memory indices + history (no write). */
|
|
97
|
+
index(entry) {
|
|
98
|
+
this.entries.push(entry);
|
|
99
|
+
if (entry.kind === "session") {
|
|
100
|
+
this.sids.add(entry.value);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const prev = this.subjectCutoffs.get(entry.value);
|
|
104
|
+
// Keep the latest cutoff so re-revoking a subject only widens the window.
|
|
105
|
+
if (prev === undefined || entry.revokedAt > prev) {
|
|
106
|
+
this.subjectCutoffs.set(entry.value, entry.revokedAt);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async persist(entry) {
|
|
111
|
+
if (!this.path)
|
|
112
|
+
return;
|
|
113
|
+
const path = this.path;
|
|
114
|
+
const line = JSON.stringify(entry) + "\n";
|
|
115
|
+
// Chain appends; ensure the parent dir + 0600 mode on the first write.
|
|
116
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
117
|
+
try {
|
|
118
|
+
await appendFile(path, line, { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err.code === "ENOENT") {
|
|
122
|
+
await mkdir(dirname(path), { recursive: true });
|
|
123
|
+
await writeFile(path, line, { mode: 0o600 });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
await this.writeChain;
|
|
130
|
+
}
|
|
131
|
+
/** Revoke one session by its sid. Idempotent. */
|
|
132
|
+
async revokeSession(sid, opts = {}) {
|
|
133
|
+
const entry = {
|
|
134
|
+
kind: "session",
|
|
135
|
+
value: sid,
|
|
136
|
+
revokedAt: this.now(),
|
|
137
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
138
|
+
...(opts.by ? { by: opts.by } : {}),
|
|
139
|
+
};
|
|
140
|
+
this.index(entry);
|
|
141
|
+
await this.persist(entry);
|
|
142
|
+
return entry;
|
|
143
|
+
}
|
|
144
|
+
/** Revoke every session for a subject issued at or before now. */
|
|
145
|
+
async revokeSubject(sub, opts = {}) {
|
|
146
|
+
const entry = {
|
|
147
|
+
kind: "subject",
|
|
148
|
+
value: sub,
|
|
149
|
+
revokedAt: this.now(),
|
|
150
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
151
|
+
...(opts.by ? { by: opts.by } : {}),
|
|
152
|
+
};
|
|
153
|
+
this.index(entry);
|
|
154
|
+
await this.persist(entry);
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Decide whether a session is revoked. Pure + synchronous so it can run
|
|
159
|
+
* on the hot path of every request without an await.
|
|
160
|
+
*/
|
|
161
|
+
isRevoked(session) {
|
|
162
|
+
if (session.sid && this.sids.has(session.sid))
|
|
163
|
+
return true;
|
|
164
|
+
const cutoff = this.subjectCutoffs.get(session.sub);
|
|
165
|
+
// `<=` so a session issued in the same second as the revocation is
|
|
166
|
+
// also caught — the operator's intent is "kill what exists now".
|
|
167
|
+
if (cutoff !== undefined && session.iat <= cutoff)
|
|
168
|
+
return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/** Snapshot of all entries, newest last (file order). */
|
|
172
|
+
list() {
|
|
173
|
+
return this.entries.map((e) => ({ ...e }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Validate + normalise an untrusted parsed JSON line into an entry. */
|
|
177
|
+
function coerceEntry(v) {
|
|
178
|
+
if (!v || typeof v !== "object")
|
|
179
|
+
return null;
|
|
180
|
+
const o = v;
|
|
181
|
+
if (o.kind !== "session" && o.kind !== "subject")
|
|
182
|
+
return null;
|
|
183
|
+
if (typeof o.value !== "string" || !o.value)
|
|
184
|
+
return null;
|
|
185
|
+
if (typeof o.revokedAt !== "number" || !Number.isFinite(o.revokedAt))
|
|
186
|
+
return null;
|
|
187
|
+
const entry = { kind: o.kind, value: o.value, revokedAt: o.revokedAt };
|
|
188
|
+
if (typeof o.reason === "string")
|
|
189
|
+
entry.reason = o.reason;
|
|
190
|
+
if (typeof o.by === "string")
|
|
191
|
+
entry.by = o.by;
|
|
192
|
+
return entry;
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { RevocationStore } from "./revocation.js";
|
|
7
|
+
function tmpFile(name = "revocations.jsonl") {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "omcp-revoke-"));
|
|
9
|
+
return join(dir, name);
|
|
10
|
+
}
|
|
11
|
+
test("memory store revokes a single session by sid", async () => {
|
|
12
|
+
const store = await RevocationStore.create();
|
|
13
|
+
assert.equal(store.persistent, false);
|
|
14
|
+
assert.equal(store.isRevoked({ sub: "alice", iat: 1000, sid: "s1" }), false);
|
|
15
|
+
await store.revokeSession("s1");
|
|
16
|
+
assert.equal(store.isRevoked({ sub: "alice", iat: 1000, sid: "s1" }), true);
|
|
17
|
+
// A different session of the same subject is untouched.
|
|
18
|
+
assert.equal(store.isRevoked({ sub: "alice", iat: 1000, sid: "s2" }), false);
|
|
19
|
+
});
|
|
20
|
+
test("session without a sid is never caught by a session-kind revocation", async () => {
|
|
21
|
+
const store = await RevocationStore.create();
|
|
22
|
+
await store.revokeSession("s1");
|
|
23
|
+
assert.equal(store.isRevoked({ sub: "alice", iat: 1000 }), false);
|
|
24
|
+
});
|
|
25
|
+
test("subject revocation catches sessions issued at or before the cutoff", async () => {
|
|
26
|
+
let clock = 5000;
|
|
27
|
+
const store = await RevocationStore.create({ now: () => clock });
|
|
28
|
+
await store.revokeSubject("bob");
|
|
29
|
+
// Issued before the cutoff → revoked.
|
|
30
|
+
assert.equal(store.isRevoked({ sub: "bob", iat: 4999, sid: "old" }), true);
|
|
31
|
+
// Issued in the same second → revoked (intent: kill what exists now).
|
|
32
|
+
assert.equal(store.isRevoked({ sub: "bob", iat: 5000, sid: "same" }), true);
|
|
33
|
+
// Issued after the cutoff (fresh login) → valid again.
|
|
34
|
+
assert.equal(store.isRevoked({ sub: "bob", iat: 5001, sid: "new" }), false);
|
|
35
|
+
// A different subject is unaffected.
|
|
36
|
+
assert.equal(store.isRevoked({ sub: "carol", iat: 1, sid: "x" }), false);
|
|
37
|
+
});
|
|
38
|
+
test("re-revoking a subject only widens the cutoff window", async () => {
|
|
39
|
+
let clock = 100;
|
|
40
|
+
const store = await RevocationStore.create({ now: () => clock });
|
|
41
|
+
await store.revokeSubject("dave"); // cutoff 100
|
|
42
|
+
clock = 200;
|
|
43
|
+
await store.revokeSubject("dave"); // cutoff 200
|
|
44
|
+
assert.equal(store.isRevoked({ sub: "dave", iat: 150, sid: "mid" }), true);
|
|
45
|
+
assert.equal(store.isRevoked({ sub: "dave", iat: 201, sid: "after" }), false);
|
|
46
|
+
});
|
|
47
|
+
test("entries persist to disk and reload into a fresh store", async () => {
|
|
48
|
+
const path = tmpFile();
|
|
49
|
+
const store = await RevocationStore.create({ path, now: () => 7000 });
|
|
50
|
+
await store.revokeSession("sid-a", { reason: "stolen laptop", by: "admin" });
|
|
51
|
+
await store.revokeSubject("eve", { reason: "offboarded" });
|
|
52
|
+
assert.equal(store.size, 2);
|
|
53
|
+
const reloaded = await RevocationStore.create({ path });
|
|
54
|
+
assert.equal(reloaded.size, 2);
|
|
55
|
+
assert.equal(reloaded.isRevoked({ sub: "x", iat: 1, sid: "sid-a" }), true);
|
|
56
|
+
assert.equal(reloaded.isRevoked({ sub: "eve", iat: 6999, sid: "z" }), true);
|
|
57
|
+
assert.equal(reloaded.isRevoked({ sub: "eve", iat: 7001, sid: "z" }), false);
|
|
58
|
+
});
|
|
59
|
+
test("persisted file is JSONL and carries the metadata", async () => {
|
|
60
|
+
const path = tmpFile();
|
|
61
|
+
const store = await RevocationStore.create({ path, now: () => 42 });
|
|
62
|
+
await store.revokeSession("sid-meta", { reason: "test", by: "root" });
|
|
63
|
+
const lines = readFileSync(path, "utf8").trim().split("\n");
|
|
64
|
+
assert.equal(lines.length, 1);
|
|
65
|
+
const entry = JSON.parse(lines[0]);
|
|
66
|
+
assert.deepEqual(entry, {
|
|
67
|
+
kind: "session",
|
|
68
|
+
value: "sid-meta",
|
|
69
|
+
revokedAt: 42,
|
|
70
|
+
reason: "test",
|
|
71
|
+
by: "root",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
test("malformed and partial lines are skipped on load", async () => {
|
|
75
|
+
const path = tmpFile();
|
|
76
|
+
writeFileSync(path, [
|
|
77
|
+
JSON.stringify({ kind: "session", value: "good", revokedAt: 1 }),
|
|
78
|
+
"not json at all",
|
|
79
|
+
JSON.stringify({ kind: "bogus", value: "x", revokedAt: 1 }), // bad kind
|
|
80
|
+
JSON.stringify({ kind: "subject", value: "", revokedAt: 1 }), // empty value
|
|
81
|
+
JSON.stringify({ kind: "subject", value: "u", revokedAt: "nope" }), // bad ts
|
|
82
|
+
"", // blank
|
|
83
|
+
JSON.stringify({ kind: "subject", value: "frank", revokedAt: 500 }),
|
|
84
|
+
].join("\n"));
|
|
85
|
+
const store = await RevocationStore.create({ path });
|
|
86
|
+
// Only the two well-formed entries survived.
|
|
87
|
+
assert.equal(store.size, 2);
|
|
88
|
+
assert.equal(store.isRevoked({ sub: "x", iat: 1, sid: "good" }), true);
|
|
89
|
+
assert.equal(store.isRevoked({ sub: "frank", iat: 400, sid: "y" }), true);
|
|
90
|
+
});
|
|
91
|
+
test("missing file is treated as an empty blocklist", async () => {
|
|
92
|
+
const path = join(mkdtempSync(join(tmpdir(), "omcp-revoke-")), "does-not-exist.jsonl");
|
|
93
|
+
const store = await RevocationStore.create({ path });
|
|
94
|
+
assert.equal(store.size, 0);
|
|
95
|
+
assert.equal(store.isRevoked({ sub: "a", iat: 1, sid: "s" }), false);
|
|
96
|
+
// First write creates the file.
|
|
97
|
+
await store.revokeSession("s");
|
|
98
|
+
assert.equal(readFileSync(path, "utf8").trim().split("\n").length, 1);
|
|
99
|
+
});
|
|
100
|
+
test("file under a non-existent directory is created on first write", async () => {
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "omcp-revoke-"));
|
|
102
|
+
const path = join(dir, "nested", "deep", "revocations.jsonl");
|
|
103
|
+
const store = await RevocationStore.create({ path });
|
|
104
|
+
await store.revokeSession("s");
|
|
105
|
+
assert.equal(readFileSync(path, "utf8").trim().split("\n").length, 1);
|
|
106
|
+
});
|
|
107
|
+
test("list() returns a defensive copy in file order", async () => {
|
|
108
|
+
const store = await RevocationStore.create({ now: () => 9 });
|
|
109
|
+
await store.revokeSession("a");
|
|
110
|
+
await store.revokeSubject("b");
|
|
111
|
+
const list = store.list();
|
|
112
|
+
assert.equal(list.length, 2);
|
|
113
|
+
assert.equal(list[0].kind, "session");
|
|
114
|
+
assert.equal(list[1].kind, "subject");
|
|
115
|
+
// Mutating the returned array/objects must not corrupt the store.
|
|
116
|
+
list[0].value = "tampered";
|
|
117
|
+
list.push({ kind: "session", value: "ghost", revokedAt: 0 });
|
|
118
|
+
assert.equal(store.list().length, 2);
|
|
119
|
+
assert.equal(store.list()[0].value, "a");
|
|
120
|
+
});
|
|
121
|
+
test("concurrent revokes all land on disk without interleaving", async () => {
|
|
122
|
+
const path = tmpFile();
|
|
123
|
+
const store = await RevocationStore.create({ path, now: () => 1 });
|
|
124
|
+
await Promise.all(Array.from({ length: 20 }, (_, i) => store.revokeSession(`s${i}`)));
|
|
125
|
+
const lines = readFileSync(path, "utf8").trim().split("\n");
|
|
126
|
+
assert.equal(lines.length, 20);
|
|
127
|
+
// Every line is independently parseable (no torn writes).
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
assert.doesNotThrow(() => JSON.parse(line));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
test("reason/by are omitted from the entry when not supplied", async () => {
|
|
133
|
+
const store = await RevocationStore.create({ now: () => 3 });
|
|
134
|
+
const entry = await store.revokeSession("s");
|
|
135
|
+
assert.deepEqual(entry, { kind: "session", value: "s", revokedAt: 3 });
|
|
136
|
+
});
|
package/dist/auth/session.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export interface SessionPayload {
|
|
|
25
25
|
tenant?: string;
|
|
26
26
|
/** Optional list of role identifiers — used by later phases for RBAC. */
|
|
27
27
|
roles?: string[];
|
|
28
|
+
/** Per-session random identifier. Minted at issue time. Lets the
|
|
29
|
+
* revocation blocklist (see ./revocation.ts) drop a single session
|
|
30
|
+
* without rotating the signing secret. Optional for backward compat:
|
|
31
|
+
* cookies issued before this field existed still verify and simply
|
|
32
|
+
* can't be revoked individually (a subject-wide revocation still
|
|
33
|
+
* catches them via `iat`). */
|
|
34
|
+
sid?: string;
|
|
28
35
|
/** Issued-at, seconds since epoch. */
|
|
29
36
|
iat: number;
|
|
30
37
|
/** Hard expiry, seconds since epoch. */
|
package/dist/auth/session.js
CHANGED
|
@@ -37,6 +37,10 @@ export function issueSession(identity, cfg, now = Math.floor(Date.now() / 1000))
|
|
|
37
37
|
email: identity.email,
|
|
38
38
|
tenant: identity.tenant,
|
|
39
39
|
roles: identity.roles,
|
|
40
|
+
// 16 random bytes ≈ 128 bits — collision-free across any realistic
|
|
41
|
+
// session population, and enough entropy that a sid can't be guessed
|
|
42
|
+
// to forge a revocation target for another user's session.
|
|
43
|
+
sid: randomBytes(16).toString("base64url"),
|
|
40
44
|
iat: now,
|
|
41
45
|
exp: now + ttl,
|
|
42
46
|
};
|
|
@@ -93,6 +97,8 @@ function isSessionPayload(v) {
|
|
|
93
97
|
return false;
|
|
94
98
|
if (o.roles !== undefined && !(Array.isArray(o.roles) && o.roles.every((r) => typeof r === "string")))
|
|
95
99
|
return false;
|
|
100
|
+
if (o.sid !== undefined && typeof o.sid !== "string")
|
|
101
|
+
return false;
|
|
96
102
|
if (o.email !== undefined && typeof o.email !== "string")
|
|
97
103
|
return false;
|
|
98
104
|
if (o.tenant !== undefined && typeof o.tenant !== "string")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { createHmac } from "node:crypto";
|
|
3
4
|
import { issueSession, verifySession, setCookieHeader, clearCookieHeader, readCookie, generateSecret, DEFAULT_COOKIE_NAME, } from "./session.js";
|
|
4
5
|
const secret = "a".repeat(48);
|
|
5
6
|
test("issueSession + verifySession — round-trips identity", () => {
|
|
@@ -12,6 +13,26 @@ test("issueSession + verifySession — round-trips identity", () => {
|
|
|
12
13
|
assert.equal(verified.sub, "alice");
|
|
13
14
|
assert.deepEqual(verified.roles, ["operator"]);
|
|
14
15
|
});
|
|
16
|
+
test("issueSession mints a unique sid that round-trips through verify", () => {
|
|
17
|
+
const now = 1_700_000_000;
|
|
18
|
+
const a = issueSession({ sub: "alice", name: "Alice" }, { secret }, now);
|
|
19
|
+
const b = issueSession({ sub: "alice", name: "Alice" }, { secret }, now);
|
|
20
|
+
assert.ok(a.payload.sid, "expected a sid");
|
|
21
|
+
assert.notEqual(a.payload.sid, b.payload.sid, "each session gets its own sid");
|
|
22
|
+
const verified = verifySession(a.cookie, { secret }, now + 1);
|
|
23
|
+
assert.ok(verified);
|
|
24
|
+
assert.equal(verified.sid, a.payload.sid);
|
|
25
|
+
});
|
|
26
|
+
test("verifySession — a legacy cookie without a sid still verifies", () => {
|
|
27
|
+
const now = 1_700_000_000;
|
|
28
|
+
// Hand-craft a payload lacking sid (pre-Q17 shape) and sign it.
|
|
29
|
+
const payload = { sub: "alice", name: "Alice", iat: now, exp: now + 3600 };
|
|
30
|
+
const payloadStr = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
31
|
+
const sig = createHmac("sha256", secret).update(payloadStr).digest("base64url");
|
|
32
|
+
const verified = verifySession(`${payloadStr}.${sig}`, { secret }, now + 1);
|
|
33
|
+
assert.ok(verified, "legacy cookie should still verify");
|
|
34
|
+
assert.equal(verified.sid, undefined);
|
|
35
|
+
});
|
|
15
36
|
test("issueSession + verifySession — round-trips email when present", () => {
|
|
16
37
|
const now = 1_700_000_000;
|
|
17
38
|
const { cookie } = issueSession({ sub: "alice", name: "Alice", email: "alice@example.test", roles: ["operator"] }, { secret }, now);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
|
|
1
|
+
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, LogAggregateQuery, LogAggregateResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
|
|
2
2
|
export interface ObservabilityConnector {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly type: string;
|
|
@@ -14,6 +14,10 @@ export interface ObservabilityConnector {
|
|
|
14
14
|
listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
|
|
15
15
|
queryMetrics?(params: MetricQuery): Promise<MetricResult>;
|
|
16
16
|
queryLogs?(params: LogQuery): Promise<LogResult>;
|
|
17
|
+
/** Optional server-side log aggregation (count/sum/topk). Backends that
|
|
18
|
+
* can push aggregation down (Loki → LogQL metric queries) implement it;
|
|
19
|
+
* the query_logs tool routes here when an `aggregate` arg is present. */
|
|
20
|
+
queryLogAggregate?(params: LogAggregateQuery): Promise<LogAggregateResult>;
|
|
17
21
|
/** Optional traces capability — Tempo / Jaeger / OTLP backends
|
|
18
22
|
* implement this. The MCP `query_traces` tool fans out to every
|
|
19
23
|
* connector that has it. */
|