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:
google-labs-jules[bot] 2026-08-24 04:23:04 +00:00
parent a938b8c305
commit 8ff5090ffa
5 changed files with 232 additions and 26 deletions

View File

@ -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

View File

@ -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(),

View File

@ -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 () => {

View File

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