Merge pull request #9 from mrteye/feat-ingress-grant-vector-injection-17171143789522307350

feat: Ingress Grant Vector Injection (/api/forward-auth)
This commit is contained in:
Tyler Gillispie 2026-08-23 21:23:16 -07:00 committed by GitHub
commit 8390dcac3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 232 additions and 26 deletions

View File

@ -70,6 +70,96 @@ export async function getAuthenticatedUser(
return null; 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. * Helper to check if user has global admin privileges.
* Strict check: Requires an explicit 'admin' grant on the Management Console * 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 // 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` await sql`
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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])); 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( return Promise.resolve(
JSON.stringify({ uuid: "user-id-1", username: "alice" }), 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", { const req = new Request("http://localhost/api/forward-auth", {
headers: { headers: {
Cookie: "session_id=mock-session-id", 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.status, 200);
assertEquals(res.headers.get("X-Forwarded-User"), "alice"); 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-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(); restoreMockSql();
valkeyStub.restore(); valkeyStub.restore();
}); });
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session", async () => { 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); const res = await app.request(req);
assertEquals(res.status, 401); assertEquals(res.status, 401);
valkeyStub.restore();
}); });
Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => { Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => {
const valkeyStub = stub(valkey, "get", () => { let valkeyStub: any;
return Promise.resolve(null); 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", { 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); const res = await app.request(req);
assertEquals(res.status, 401); assertEquals(res.status, 401);
@ -79,14 +131,28 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => {
setMockSql(() => Promise.resolve([mockUser])); setMockSql(() => Promise.resolve([mockUser]));
const valkeyStub = stub(valkey, "get", () => { let valkeyStub: any;
return Promise.resolve( if ((valkey.get as any).restore) {
JSON.stringify({ uuid: "user-id-1", username: "alice" }), 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", { 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); const res = await app.request(req);
assertEquals(res.status, 403); assertEquals(res.status, 403);
@ -112,12 +178,16 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => {
// Re-define valkey stub // Re-define valkey stub
const originalValkeyGet = valkey.get; let valkeyStub: any;
valkey.get = () => { if ((valkey.get as any).restore) {
return Promise.resolve( valkeyStub = valkey.get as any;
JSON.stringify({ uuid: "user-1", username: "alice" }), } else {
); valkeyStub = stub(valkey, "get", () => {
}; return Promise.resolve(
JSON.stringify({ uuid: "user-1", username: "alice" }),
);
});
}
let auditCalled = false; let auditCalled = false;
const originalAudit = auditWrapper.auditLog; const originalAudit = auditWrapper.auditLog;
@ -158,7 +228,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => {
spireWrapper.extractSpiffeIdFromCert = originalExtract; spireWrapper.extractSpiffeIdFromCert = originalExtract;
restoreMockSql(); restoreMockSql();
valkey.get = originalValkeyGet; valkeyStub.restore();
auditWrapper.auditLog = originalAudit; auditWrapper.auditLog = originalAudit;
}); });
@ -176,12 +246,16 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => {
} }
}); });
const originalValkeyGet = valkey.get; let valkeyStub: any;
valkey.get = () => { if ((valkey.get as any).restore) {
return Promise.resolve( valkeyStub = valkey.get as any;
JSON.stringify({ uuid: "user-1", username: "alice" }), } else {
); valkeyStub = stub(valkey, "get", () => {
}; return Promise.resolve(
JSON.stringify({ uuid: "user-1", username: "alice" }),
);
});
}
const req = new Request( const req = new Request(
"http://localhost/auth.v1.AuthService/ValidateSession", "http://localhost/auth.v1.AuthService/ValidateSession",
@ -206,7 +280,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => {
spireWrapper.extractSpiffeIdFromCert = originalExtract; spireWrapper.extractSpiffeIdFromCert = originalExtract;
restoreMockSql(); restoreMockSql();
valkey.get = originalValkeyGet; valkeyStub.restore();
}); });
Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", async () => { 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) // Traefik ForwardAuth Edge Proxy Route (Tier 2)
// --------------------------------------------------------- // ---------------------------------------------------------
import { getAppByHost, getUserGrant } from "./auth-session.ts";
app.get("/api/forward-auth", async (c) => { 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); const auth = await getAuthenticatedUser(c);
if (!auth) { if (!auth) {
return c.text("Unauthorized", 401); 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` const user = await sqlWrapper.sql`
SELECT id, username, account_status SELECT id, username, account_status
FROM users FROM users
@ -978,8 +995,26 @@ app.get("/api/forward-auth", async (c) => {
return c.text("Forbidden: Account inactive", 403); 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", user.username);
c.header("X-Forwarded-User-Id", user.id); 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); return c.text("OK", 200);
}); });