mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
docs: add management authentication terminology guide (#7786)
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))
|
||||||
41
.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md
Normal file
41
.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Management Authentication
|
||||||
|
|
||||||
|
OmniRoute uses four distinct credential families for management access. This guide
|
||||||
|
distinguishes them by purpose, scope, and locality.
|
||||||
|
|
||||||
|
| Credential | Scope | Locality | Use Case |
|
||||||
|
|-------------------------|--------------------|---------------|-----------------------------------|
|
||||||
|
| Dashboard JWT session | Full management | Localhost | Web dashboard login |
|
||||||
|
| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands |
|
||||||
|
| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access |
|
||||||
|
| Manage-scope API key | `manage` scope | External | Management API calls |
|
||||||
|
|
||||||
|
## Dashboard JWT Session
|
||||||
|
|
||||||
|
Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie.
|
||||||
|
Valid for the session duration. Cannot be used from external hosts.
|
||||||
|
|
||||||
|
## CLI Machine-ID Token
|
||||||
|
|
||||||
|
Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`.
|
||||||
|
Used by the CLI for all management operations. Tied to the machine identity.
|
||||||
|
|
||||||
|
## Scoped `oma_` Access Token
|
||||||
|
|
||||||
|
Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`).
|
||||||
|
Format: `oma_<random-hex>`. Used for programmatic access from external systems.
|
||||||
|
|
||||||
|
## Manage-Scope API Key
|
||||||
|
|
||||||
|
Standard API key with the `manage` scope enabled. Created in dashboard API Keys page.
|
||||||
|
Used for management API calls from external hosts.
|
||||||
|
|
||||||
|
## Header Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer oma_abc123def456
|
||||||
|
Authorization: Bearer <standard-api-key-with-manage-scope>
|
||||||
|
Cookie: omniroute_session=<jwt-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { ok } from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
describe("Management auth documentation (#7786)", () => {
|
||||||
|
const docPath = "docs/guides/MANAGEMENT-AUTH.md";
|
||||||
|
const content = readFileSync(docPath, "utf-8");
|
||||||
|
|
||||||
|
it("exists and has content", () => {
|
||||||
|
ok(content.length > 500, "should have substantial content");
|
||||||
|
ok(content.includes("Dashboard JWT session"));
|
||||||
|
ok(content.includes("CLI machine-id token"));
|
||||||
|
ok(content.includes("oma_"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents all four credential families", () => {
|
||||||
|
const families = ["Dashboard JWT", "CLI machine-id", "oma_", "Manage-scope"];
|
||||||
|
for (const f of families) {
|
||||||
|
ok(content.includes(f), `should document ${f}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions relevant auth header examples", () => {
|
||||||
|
ok(content.includes("Authorization"));
|
||||||
|
ok(content.includes("Bearer"));
|
||||||
|
});
|
||||||
|
});
|
||||||
1
changelog.d/fixes/9159-fix.plan.md
Normal file
1
changelog.d/fixes/9159-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)
|
||||||
128
tests/unit/authz/probe-9033-repro.test.ts
Normal file
128
tests/unit/authz/probe-9033-repro.test.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// Repro test for #9033 — IP blacklist does not block on direct connections
|
||||||
|
// and does not propagate without restart.
|
||||||
|
// D1: blacklisted IP on a DIRECT connection (trusted peer stamp, no XFF) is NOT blocked
|
||||||
|
// D2: persisted config written after first load is never re-read by the loaded instance
|
||||||
|
// Bonus: ipFilterModeSchema rejects "whitelist-priority" that the UI offers and checkIP implements
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9033-repro-"));
|
||||||
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||||
|
process.env.JWT_SECRET = "test-secret-9033";
|
||||||
|
|
||||||
|
const core = await import("../../../src/lib/db/core.ts");
|
||||||
|
const ipFilter = await import("../../../open-sse/services/ipFilter.ts");
|
||||||
|
const pipeline = await import("../../../src/server/authz/pipeline.ts");
|
||||||
|
|
||||||
|
const ORIGINAL_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
core.resetDbInstance();
|
||||||
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_STAMP_TOKEN === undefined) delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||||
|
else process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_STAMP_TOKEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
core.resetDbInstance();
|
||||||
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||||
|
ipFilter.resetIPFilter();
|
||||||
|
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
const BLOCKED = "203.0.113.99";
|
||||||
|
|
||||||
|
function makeRequest(extraHeaders: Record<string, string> = {}) {
|
||||||
|
return new NextRequest("http://localhost/v1/models", {
|
||||||
|
headers: { ...extraHeaders },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("D1: blacklisted IP is blocked on a DIRECT connection (trusted peer stamp, no XFF)", async () => {
|
||||||
|
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "stamp-tok";
|
||||||
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
||||||
|
ipFilter.addToBlacklist(BLOCKED);
|
||||||
|
|
||||||
|
// Simulate a direct connection: the peer stamp says the client is BLOCKED,
|
||||||
|
// and there is no x-forwarded-for header (direct connection, not via proxy).
|
||||||
|
const res = await pipeline.runAuthzPipeline(
|
||||||
|
makeRequest({ "x-omniroute-peer-ip": "stamp-tok|203.0.113.99" }),
|
||||||
|
{ enforce: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
res.status,
|
||||||
|
403,
|
||||||
|
`direct blacklisted IP must be blocked, got status=${res.status}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("D2: persisted config written after first load is honored WITHOUT restart", async () => {
|
||||||
|
// Simulate: the settings route (separate module instance) writes config to DB.
|
||||||
|
// The ipFilter module instance (already loaded) must re-read it.
|
||||||
|
// First, load the module once (simulates initial load from a previous request).
|
||||||
|
ipFilter.resetIPFilter();
|
||||||
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
||||||
|
ipFilter.addToBlacklist(BLOCKED);
|
||||||
|
assert.equal(ipFilter.checkIP(BLOCKED).allowed, false, "blacklist must be active after config");
|
||||||
|
|
||||||
|
// Now simulate a "settings route" write: write directly to the DB key_value table
|
||||||
|
// with a DIFFERENT config (e.g. empty blacklist, effectively "allow all").
|
||||||
|
const db = core.getDbInstance();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||||
|
).run(
|
||||||
|
"ipFilter",
|
||||||
|
"config",
|
||||||
|
JSON.stringify({ enabled: true, mode: "blacklist", blacklist: [], whitelist: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without a restart, the ipFilter instance must re-read from DB on next checkIP call.
|
||||||
|
// The BLOCKED IP should NOT be blocked anymore because the DB config has empty blacklist.
|
||||||
|
const result = ipFilter.checkIP(BLOCKED);
|
||||||
|
assert.equal(
|
||||||
|
result.allowed,
|
||||||
|
true,
|
||||||
|
`stale-config enforcer must re-read DB, got: ${JSON.stringify(result)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("D3: behind reverse proxy (peer stamp=loopback + via-proxy marker + XFF=blacklisted IP) still blocks", async () => {
|
||||||
|
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "stamp-tok";
|
||||||
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
||||||
|
ipFilter.addToBlacklist(BLOCKED);
|
||||||
|
|
||||||
|
// Behind a reverse proxy: the peer IP is the proxy hop (127.0.0.1),
|
||||||
|
// the via-proxy marker is set, and the real client IP is in x-forwarded-for.
|
||||||
|
const res = await pipeline.runAuthzPipeline(
|
||||||
|
makeRequest({
|
||||||
|
"x-omniroute-peer-ip": "stamp-tok|127.0.0.1",
|
||||||
|
"x-omniroute-via-proxy": "stamp-tok|1",
|
||||||
|
"x-forwarded-for": BLOCKED,
|
||||||
|
}),
|
||||||
|
{ enforce: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
res.status,
|
||||||
|
403,
|
||||||
|
`behind-proxy blacklisted IP must be blocked, got status=${res.status}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bonus: ipFilterModeSchema accepts whitelist-priority", async () => {
|
||||||
|
const { ipFilterModeSchema } = await import(
|
||||||
|
"../../../src/shared/validation/schemas/misc.ts"
|
||||||
|
);
|
||||||
|
const result = ipFilterModeSchema.safeParse("whitelist-priority");
|
||||||
|
assert.equal(
|
||||||
|
result.success,
|
||||||
|
true,
|
||||||
|
`ipFilterModeSchema must accept "whitelist-priority", got: ${JSON.stringify(result)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
45
tests/unit/repro-8522.test.ts
Normal file
45
tests/unit/repro-8522.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* repro-8522 — quality-gate inherited-drift defect.
|
||||||
|
*
|
||||||
|
* Issue #8522: check:file-size (and the eslint-suppressions count) are ABSOLUTE
|
||||||
|
* ratchets with no base-ref comparison. Once the release base is over a frozen
|
||||||
|
* cap (inherited drift from an already-merged PR), EVERY subsequent PR goes red
|
||||||
|
* on that gate regardless of content — the "innocent PR" cannot pass, so red
|
||||||
|
* stops distinguishing "you broke it" from "you exist".
|
||||||
|
*
|
||||||
|
* This test reproduces the minimal defect: an innocent PR (base and head have
|
||||||
|
* IDENTICAL LOC on the frozen file, PR touched nothing) still produces a
|
||||||
|
* violation, because `evaluateFileSizes` compares head LOC to the frozen number
|
||||||
|
* with no notion of the base.
|
||||||
|
*/
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { evaluateFileSizes } from "../../scripts/check/check-file-size.mjs";
|
||||||
|
|
||||||
|
test("8522: innocent PR (base already over frozen cap) must NOT be a violation", () => {
|
||||||
|
// Scenario: frozen cap for src/foo.ts is 100. Some earlier merged PR grew it
|
||||||
|
// to 110. The base of THIS PR is therefore 110. This PR is innocent — it does
|
||||||
|
// not touch src/foo.ts at all, so head LOC == base LOC == 110.
|
||||||
|
const baseLocByFile = { "src/foo.ts": 110 };
|
||||||
|
const currentLocByFile = { ...baseLocByFile }; // PR changed nothing in foo.ts
|
||||||
|
const frozen = { "src/foo.ts": 100 };
|
||||||
|
const cap = 100;
|
||||||
|
|
||||||
|
const { violations } = evaluateFileSizes(currentLocByFile, frozen, cap);
|
||||||
|
|
||||||
|
// The gate has no base-ref input; it compares head LOC (110) to frozen (100)
|
||||||
|
// and flags a violation. But the PR introduced ZERO growth — it is a false
|
||||||
|
// positive on inherited drift.
|
||||||
|
assert.deepEqual(violations, [], "innocent PR flagged for inherited drift");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8522: PR that DOES grow a frozen file above frozen cap is a violation", () => {
|
||||||
|
// Sanity: the gate must still catch a PR that grows the file above its cap.
|
||||||
|
const baseLocByFile = { "src/foo.ts": 100 };
|
||||||
|
const currentLocByFile = { "src/foo.ts": 112 }; // PR grew it +12
|
||||||
|
const frozen = { "src/foo.ts": 100 };
|
||||||
|
const cap = 100;
|
||||||
|
|
||||||
|
const { violations } = evaluateFileSizes(currentLocByFile, frozen, cap);
|
||||||
|
assert.equal(violations.length, 1, "own-growth PR must be a violation");
|
||||||
|
});
|
||||||
65
tests/unit/repro-8956.test.ts
Normal file
65
tests/unit/repro-8956.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const { resolveProjectRoot } = await import("../../src/lib/system/autoUpdate.ts");
|
||||||
|
|
||||||
|
test("repro-8956: resolveProjectRoot skips synthetic .build/next/package.json (no name field)", () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repro-8956-"));
|
||||||
|
try {
|
||||||
|
// Simulate a Next.js standalone build layout inside a real repo:
|
||||||
|
// <tmp>/repo/.git/ (real git marker)
|
||||||
|
// <tmp>/repo/package.json (real repo root, has a "name" field)
|
||||||
|
// <tmp>/repo/.build/next/package.json (synthetic marker, {"type":"commonjs"}, no name)
|
||||||
|
// <tmp>/repo/.build/next/server/chunks/ (where the bundled module lives at runtime)
|
||||||
|
const repoRoot = path.join(tmp, "repo");
|
||||||
|
const buildPkgDir = path.join(repoRoot, ".build", "next");
|
||||||
|
const chunksDir = path.join(buildPkgDir, "server", "chunks");
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
||||||
|
fs.mkdirSync(chunksDir, { recursive: true });
|
||||||
|
|
||||||
|
// Real root package.json with a name
|
||||||
|
fs.writeFileSync(path.join(repoRoot, "package.json"), JSON.stringify({ name: "omniroute" }));
|
||||||
|
// Synthetic Next.js standalone build marker — no "name" field
|
||||||
|
fs.writeFileSync(path.join(buildPkgDir, "package.json"), JSON.stringify({ type: "commonjs" }));
|
||||||
|
|
||||||
|
// Start from the chunks dir (simulating __dirname at runtime)
|
||||||
|
const root = resolveProjectRoot("/fallback", chunksDir);
|
||||||
|
|
||||||
|
// Must NOT stop at .build/next — must walk up to the repo root that has .git
|
||||||
|
assert.equal(
|
||||||
|
root,
|
||||||
|
repoRoot,
|
||||||
|
`resolveProjectRoot returned ${root}, expected the repo root ${repoRoot} ` +
|
||||||
|
"(it stopped at the synthetic .build/next/package.json marker)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The resolved root must own .git so source-mode validation passes
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(root, ".git")),
|
||||||
|
`PROJECT_ROOT resolved to ${root}, which lacks .git`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repro-8956: resolveProjectRoot still finds package.json with a name field", () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repro-8956-named-"));
|
||||||
|
try {
|
||||||
|
// A normal repo root: has .git AND a named package.json
|
||||||
|
const repoRoot = path.join(tmp, "my-repo");
|
||||||
|
const subDir = path.join(repoRoot, "some", "deep", "path");
|
||||||
|
fs.mkdirSync(subDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(repoRoot, "package.json"), JSON.stringify({ name: "my-app" }));
|
||||||
|
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
||||||
|
|
||||||
|
const root = resolveProjectRoot("/fallback", subDir);
|
||||||
|
assert.equal(root, repoRoot);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user