docs: add management authentication terminology guide (#7786)

This commit is contained in:
diegosouzapw
2026-08-04 18:32:28 -03:00
parent 2c966c28af
commit f4e93f339d
7 changed files with 308 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))

View 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.

View File

@@ -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"));
});
});

View File

@@ -0,0 +1 @@
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)

View 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)}`
);
});

View 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");
});

View 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 });
}
});