Compare commits
2 Commits
57570a0976
...
8390dcac3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8390dcac3d | |||
|
|
8ff5090ffa |
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user