feat: Implement Ingress Grant Vector Injection for ForwardAuth
- Add `domain` column to `apps` table. - Create Valkey caching layers for app resolution by host (`auth:app_by_host:<host>`) and user grants (`auth:grants:<userId>:<appId>`) with PostgreSQL fallback in `server/auth-session.ts`. - Update `/api/forward-auth` endpoint to resolve `X-Forwarded-Host`, enforce Default-Deny, check RBAC grants, and inject `X-Forwarded-*` scopes. - Update relevant unit tests to cover missing and invalid scenarios with correct Mock stubs. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
a938b8c305
commit
8ff5090ffa
@ -70,6 +70,96 @@ export async function getAuthenticatedUser(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get an application by host.
|
||||
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
|
||||
*/
|
||||
export async function getAppByHost(
|
||||
host: string,
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const cacheKey = `auth:app_by_host:${host}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const cachedStr = await valkey.get(cacheKey);
|
||||
if (cachedStr) {
|
||||
return JSON.parse(cachedStr);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection error
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL
|
||||
try {
|
||||
// Search by domain first
|
||||
let app = await sqlWrapper.sql`
|
||||
SELECT id, name FROM apps WHERE domain = ${host}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
// Fallback: match name against the first subdomain segment
|
||||
if (!app) {
|
||||
const subdomain = host.split(".")[0];
|
||||
if (subdomain) {
|
||||
app = await sqlWrapper.sql`
|
||||
SELECT id, name FROM apps WHERE name = ${subdomain}
|
||||
`.then((res: any) => res[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (app) {
|
||||
// Repopulate Valkey
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return app;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a user's role grant for a specific app.
|
||||
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
|
||||
*/
|
||||
export async function getUserGrant(
|
||||
userId: string,
|
||||
appId: string,
|
||||
): Promise<string | null> {
|
||||
const cacheKey = `auth:grants:${userId}:${appId}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const cachedRole = await valkey.get(cacheKey);
|
||||
if (cachedRole) {
|
||||
return cachedRole;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection error
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL
|
||||
try {
|
||||
const grant = await sqlWrapper.sql`
|
||||
SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (grant && grant.role) {
|
||||
// Repopulate Valkey
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, grant.role); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return grant.role;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if user has global admin privileges.
|
||||
* Strict check: Requires an explicit 'admin' grant on the Management Console
|
||||
|
||||
@ -62,6 +62,13 @@ export async function initDb(): Promise<void> {
|
||||
// Ignore and check next
|
||||
}
|
||||
|
||||
// Ensure domain column exists for Edge authorization routing
|
||||
try {
|
||||
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS domain TEXT UNIQUE`;
|
||||
} catch {
|
||||
// Soft ignore if column already exists
|
||||
}
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@ -30,7 +30,24 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
|
||||
|
||||
setMockSql(() => Promise.resolve([mockUser]));
|
||||
|
||||
const valkeyStub = stub(valkey, "get", () => {
|
||||
let queryCount = 0;
|
||||
|
||||
setMockSql(() => {
|
||||
queryCount++;
|
||||
if (queryCount === 1) return Promise.resolve([mockUser]); // User lookup
|
||||
return Promise.resolve([]); // isGlobalAdmin lookup
|
||||
});
|
||||
|
||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
const k = String(key);
|
||||
if (k.startsWith("auth:app_by_host:")) {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
||||
);
|
||||
}
|
||||
if (k.startsWith("auth:grants:")) {
|
||||
return Promise.resolve("viewer");
|
||||
}
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-id-1", username: "alice" }),
|
||||
);
|
||||
@ -39,6 +56,7 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
|
||||
const req = new Request("http://localhost/api/forward-auth", {
|
||||
headers: {
|
||||
Cookie: "session_id=mock-session-id",
|
||||
"X-Forwarded-Host": "test.app.local",
|
||||
},
|
||||
});
|
||||
|
||||
@ -46,24 +64,58 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("X-Forwarded-User"), "alice");
|
||||
assertEquals(res.headers.get("X-Forwarded-User-Id"), "user-id-1");
|
||||
assertEquals(res.headers.get("X-Forwarded-Scopes"), "viewer");
|
||||
assertEquals(res.headers.get("X-Forwarded-App-Id"), "app-id-1");
|
||||
|
||||
restoreMockSql();
|
||||
valkeyStub.restore();
|
||||
});
|
||||
|
||||
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session", async () => {
|
||||
const req = new Request("http://localhost/api/forward-auth");
|
||||
const req = new Request("http://localhost/api/forward-auth", {
|
||||
headers: { "X-Forwarded-Host": "test.app.local" },
|
||||
});
|
||||
|
||||
let valkeyStub: any;
|
||||
if ((valkey.get as any).restore) {
|
||||
valkeyStub = valkey.get as any;
|
||||
} else {
|
||||
valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
const k = String(key);
|
||||
if (k.startsWith("auth:app_by_host:")) {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
}
|
||||
const res = await app.request(req);
|
||||
assertEquals(res.status, 401);
|
||||
valkeyStub.restore();
|
||||
});
|
||||
|
||||
Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => {
|
||||
const valkeyStub = stub(valkey, "get", () => {
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
let valkeyStub: any;
|
||||
if ((valkey.get as any).restore) {
|
||||
valkeyStub = valkey.get as any;
|
||||
} else {
|
||||
valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
const k = String(key);
|
||||
if (k.startsWith("auth:app_by_host:")) {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
const req = new Request("http://localhost/api/forward-auth", {
|
||||
headers: { Cookie: "session_id=expired-session-id" },
|
||||
headers: {
|
||||
Cookie: "session_id=expired-session-id",
|
||||
"X-Forwarded-Host": "test.app.local",
|
||||
},
|
||||
});
|
||||
const res = await app.request(req);
|
||||
assertEquals(res.status, 401);
|
||||
@ -79,14 +131,28 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => {
|
||||
|
||||
setMockSql(() => Promise.resolve([mockUser]));
|
||||
|
||||
const valkeyStub = stub(valkey, "get", () => {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-id-1", username: "alice" }),
|
||||
);
|
||||
});
|
||||
let valkeyStub: any;
|
||||
if ((valkey.get as any).restore) {
|
||||
valkeyStub = valkey.get as any;
|
||||
} else {
|
||||
valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
const k = String(key);
|
||||
if (k.startsWith("auth:app_by_host:")) {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-id-1", username: "alice" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const req = new Request("http://localhost/api/forward-auth", {
|
||||
headers: { Cookie: "session_id=mock-session-id" },
|
||||
headers: {
|
||||
Cookie: "session_id=mock-session-id",
|
||||
"X-Forwarded-Host": "test.app.local",
|
||||
},
|
||||
});
|
||||
const res = await app.request(req);
|
||||
assertEquals(res.status, 403);
|
||||
@ -112,12 +178,16 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => {
|
||||
|
||||
// Re-define valkey stub
|
||||
|
||||
const originalValkeyGet = valkey.get;
|
||||
valkey.get = () => {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-1", username: "alice" }),
|
||||
);
|
||||
};
|
||||
let valkeyStub: any;
|
||||
if ((valkey.get as any).restore) {
|
||||
valkeyStub = valkey.get as any;
|
||||
} else {
|
||||
valkeyStub = stub(valkey, "get", () => {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-1", username: "alice" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let auditCalled = false;
|
||||
const originalAudit = auditWrapper.auditLog;
|
||||
@ -158,7 +228,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => {
|
||||
|
||||
spireWrapper.extractSpiffeIdFromCert = originalExtract;
|
||||
restoreMockSql();
|
||||
valkey.get = originalValkeyGet;
|
||||
valkeyStub.restore();
|
||||
auditWrapper.auditLog = originalAudit;
|
||||
});
|
||||
|
||||
@ -176,12 +246,16 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const originalValkeyGet = valkey.get;
|
||||
valkey.get = () => {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-1", username: "alice" }),
|
||||
);
|
||||
};
|
||||
let valkeyStub: any;
|
||||
if ((valkey.get as any).restore) {
|
||||
valkeyStub = valkey.get as any;
|
||||
} else {
|
||||
valkeyStub = stub(valkey, "get", () => {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "user-1", username: "alice" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/auth.v1.AuthService/ValidateSession",
|
||||
@ -206,7 +280,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => {
|
||||
|
||||
spireWrapper.extractSpiffeIdFromCert = originalExtract;
|
||||
restoreMockSql();
|
||||
valkey.get = originalValkeyGet;
|
||||
valkeyStub.restore();
|
||||
});
|
||||
|
||||
Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", async () => {
|
||||
|
||||
@ -962,12 +962,29 @@ app.post("/api/admin/users/:id/status", async (c) => {
|
||||
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
import { getAppByHost, getUserGrant } from "./auth-session.ts";
|
||||
|
||||
app.get("/api/forward-auth", async (c) => {
|
||||
const host = c.req.header("X-Forwarded-Host");
|
||||
if (!host) {
|
||||
return c.text("Bad Request: Missing X-Forwarded-Host header", 400);
|
||||
}
|
||||
|
||||
// 1. Resolve Target App (Valkey -> DB)
|
||||
const appRecord = await getAppByHost(host);
|
||||
if (!appRecord) {
|
||||
// Default-Deny if app is not registered
|
||||
return c.text("Forbidden: Application not registered", 403);
|
||||
}
|
||||
|
||||
// 2. Validate Session
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
// Cache lookup for user status can be added later; hitting DB to be safe for now,
|
||||
// but let's just make sure account is active.
|
||||
const user = await sqlWrapper.sql`
|
||||
SELECT id, username, account_status
|
||||
FROM users
|
||||
@ -978,8 +995,26 @@ app.get("/api/forward-auth", async (c) => {
|
||||
return c.text("Forbidden: Account inactive", 403);
|
||||
}
|
||||
|
||||
// 3. Resolve Grants and Roles
|
||||
const globalAdmin = await isGlobalAdmin(auth.userId);
|
||||
const grantRole = await getUserGrant(auth.userId, appRecord.id);
|
||||
|
||||
if (!globalAdmin && !grantRole) {
|
||||
// Enforce Default-Deny if no app-specific grants and not global admin
|
||||
return c.text("Forbidden: Access denied to this application", 403);
|
||||
}
|
||||
|
||||
// Combine scopes, ensuring no duplicates and formatting as comma-separated string
|
||||
const scopes = [
|
||||
...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)),
|
||||
].join(",");
|
||||
|
||||
// 4. Inject Headers
|
||||
c.header("X-Forwarded-User", user.username);
|
||||
c.header("X-Forwarded-User-Id", user.id);
|
||||
c.header("X-Forwarded-Scopes", scopes);
|
||||
c.header("X-Forwarded-App-Id", appRecord.id);
|
||||
|
||||
return c.text("OK", 200);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user