Compare commits
No commits in common. "6f18b156ff99550e3b29fdada17c6b40de3ba8d7" and "f2310503f49309f420c2f637a8000b7c0f0b345b" have entirely different histories.
6f18b156ff
...
f2310503f4
@ -1,39 +1,410 @@
|
|||||||
import type { Context } from "jsr:@hono/hono@4";
|
import type { Context } from "jsr:@hono/hono@4";
|
||||||
|
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
|
import { sqlWrapper } from "./db.ts";
|
||||||
|
import { valkey } from "./valkey.ts";
|
||||||
|
|
||||||
import {
|
export interface AuthenticatedUser {
|
||||||
getClientIp,
|
userId: string;
|
||||||
getCookieDomain,
|
sessionId: string;
|
||||||
isIpAllowed,
|
username: string;
|
||||||
isPathBypassed,
|
label?: string;
|
||||||
isSafeRedirectUrl,
|
isAgent?: boolean;
|
||||||
} from "./forward_auth.ts";
|
customScopes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
export interface AppRecord {
|
||||||
type AppRecord,
|
id: string;
|
||||||
type AuthenticatedUser,
|
name: string;
|
||||||
extractAllSessionIds,
|
domain?: string;
|
||||||
getAppByHost,
|
is_public?: boolean;
|
||||||
getAuthenticatedUser,
|
bypass_paths?: string[];
|
||||||
getUserGrant,
|
allowed_cidrs?: string[];
|
||||||
isGlobalAdmin,
|
}
|
||||||
} from "./session_resolver.ts";
|
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
/**
|
||||||
export {
|
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org)
|
||||||
type AppRecord,
|
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
|
||||||
type AuthenticatedUser,
|
*/
|
||||||
extractAllSessionIds,
|
export function getCookieDomain(customRpId?: string): string | undefined {
|
||||||
getAppByHost,
|
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
||||||
getAuthenticatedUser,
|
if (envDomain) {
|
||||||
getClientIp,
|
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
||||||
getCookieDomain,
|
}
|
||||||
getUserGrant,
|
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
||||||
isGlobalAdmin,
|
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
||||||
isIpAllowed,
|
return undefined;
|
||||||
isPathBypassed,
|
}
|
||||||
isSafeRedirectUrl,
|
const parts = targetId.split(".").filter(Boolean);
|
||||||
};
|
if (parts.length >= 2) {
|
||||||
|
return `.${parts.slice(-2).join(".")}`;
|
||||||
|
}
|
||||||
|
return `.${targetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get authenticated user from session cookie or Authorization header.
|
||||||
|
* Checks Valkey cache first, with automatic PostgreSQL sessions table fallback.
|
||||||
|
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all session_id tokens from the Cookie and Authorization headers.
|
||||||
|
*/
|
||||||
|
export function extractAllSessionIds(c: Context): string[] {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
// 1. Check Authorization: Bearer <token>
|
||||||
|
const authHeader = c.req.header("authorization") || "";
|
||||||
|
if (authHeader.startsWith("Bearer ")) {
|
||||||
|
const bearerToken = authHeader.substring(7).trim();
|
||||||
|
if (bearerToken) candidates.push(bearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Cookie header
|
||||||
|
const cookieHeader = c.req.header("cookie") || "";
|
||||||
|
if (cookieHeader) {
|
||||||
|
const cookieMatches = [
|
||||||
|
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
|
||||||
|
]
|
||||||
|
.map((m) => decodeURIComponent(m[1].trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
candidates.push(...cookieMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthenticatedUser(
|
||||||
|
c: Context,
|
||||||
|
): Promise<AuthenticatedUser | null> {
|
||||||
|
const sessionMatches = extractAllSessionIds(c);
|
||||||
|
if (sessionMatches.length === 0) return null;
|
||||||
|
|
||||||
|
// Iterate over each candidate session ID
|
||||||
|
for (let i = 0; i < sessionMatches.length; i++) {
|
||||||
|
const candidateId = sessionMatches[i];
|
||||||
|
|
||||||
|
// 1. Try Valkey cache
|
||||||
|
try {
|
||||||
|
const sessionDataStr = await valkey.get(candidateId);
|
||||||
|
if (sessionDataStr) {
|
||||||
|
const sessionData = JSON.parse(sessionDataStr);
|
||||||
|
if (sessionData && sessionData.uuid) {
|
||||||
|
if (i > 0) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: sessionData.uuid,
|
||||||
|
sessionId: candidateId,
|
||||||
|
username: sessionData.username || "",
|
||||||
|
label: sessionData.label,
|
||||||
|
isAgent: sessionData.isAgent,
|
||||||
|
customScopes: sessionData.customScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
// Valkey cache miss or connection hiccup - fallback to DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to PostgreSQL sessions table
|
||||||
|
try {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const session = await sqlWrapper.sql`
|
||||||
|
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
|
||||||
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const username = session.username || "";
|
||||||
|
// Repopulate Valkey in background
|
||||||
|
try {
|
||||||
|
const ttlSeconds = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await valkey.setex(
|
||||||
|
candidateId,
|
||||||
|
ttlSeconds,
|
||||||
|
JSON.stringify({
|
||||||
|
uuid: session.user_id,
|
||||||
|
username,
|
||||||
|
label: session.label,
|
||||||
|
isAgent: session.is_agent,
|
||||||
|
customScopes: session.custom_scopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: session.user_id,
|
||||||
|
sessionId: candidateId,
|
||||||
|
username,
|
||||||
|
label: session.label,
|
||||||
|
isAgent: session.is_agent,
|
||||||
|
customScopes: session.custom_scopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
// Continue to next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppRecord | 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, domain, is_public, bypass_paths, allowed_cidrs
|
||||||
|
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, domain, is_public, bypass_paths, allowed_cidrs
|
||||||
|
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 as AppRecord;
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
* or global role, or is the bootstrap root user.
|
||||||
|
*/
|
||||||
|
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
|
||||||
|
const adminGrant = await sqlWrapper.sql`
|
||||||
|
SELECT g.id
|
||||||
|
FROM grants g
|
||||||
|
LEFT JOIN apps a ON g.app_id = a.id
|
||||||
|
WHERE g.user_id = ${userId}
|
||||||
|
AND g.role = 'admin'
|
||||||
|
AND (
|
||||||
|
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
|
||||||
|
OR a.name = 'Auth-Yes Management Console'
|
||||||
|
OR g.app_id IS NULL
|
||||||
|
)
|
||||||
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
|
if (adminGrant) return true;
|
||||||
|
|
||||||
|
// Check 2: First registered user in system fallback
|
||||||
|
const firstUser = await sqlWrapper.sql`
|
||||||
|
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
|
||||||
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
|
if (firstUser && firstUser.id === userId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Auth API] isGlobalAdmin error:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic fast path prefix matcher for dynamic bypasses
|
||||||
|
*/
|
||||||
|
export function isPathBypassed(
|
||||||
|
requestPath: string,
|
||||||
|
bypassPaths?: string[],
|
||||||
|
): boolean {
|
||||||
|
if (!bypassPaths || bypassPaths.length === 0) return false;
|
||||||
|
|
||||||
|
for (const pattern of bypassPaths) {
|
||||||
|
if (pattern === requestPath) return true;
|
||||||
|
if (pattern.endsWith("/*")) {
|
||||||
|
const prefix = pattern.slice(0, -2); // remove /*
|
||||||
|
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure native Deno bitwise CIDR matcher
|
||||||
|
*/
|
||||||
|
export function isIpAllowed(
|
||||||
|
clientIp: string,
|
||||||
|
allowedCidrs?: string[],
|
||||||
|
): boolean {
|
||||||
|
if (!allowedCidrs || allowedCidrs.length === 0) return false;
|
||||||
|
|
||||||
|
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
|
||||||
|
const parseIp4 = (ip: string) => {
|
||||||
|
const parts = ip.split(".");
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
|
||||||
|
0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
|
||||||
|
// We extract the first IP (the original client)
|
||||||
|
const primaryIp = clientIp.split(",")[0].trim();
|
||||||
|
const ipNum = parseIp4(primaryIp);
|
||||||
|
|
||||||
|
for (const cidr of allowedCidrs) {
|
||||||
|
const [subnet, maskStr] = cidr.split("/");
|
||||||
|
if (!maskStr) {
|
||||||
|
if (subnet === primaryIp) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4 CIDR matching
|
||||||
|
if (ipNum !== null && subnet.includes(".")) {
|
||||||
|
const subnetNum = parseIp4(subnet);
|
||||||
|
if (subnetNum !== null) {
|
||||||
|
const maskBits = parseInt(maskStr, 10);
|
||||||
|
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
|
||||||
|
const mask = maskBits === 0
|
||||||
|
? 0
|
||||||
|
: ((0xffffffff << (32 - maskBits)) >>> 0);
|
||||||
|
if ((ipNum & mask) === (subnetNum & mask)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
|
||||||
|
// Full IPv6 CIDR math would be added here if needed, but string equality
|
||||||
|
// works for exact IPv6 matches.
|
||||||
|
else if (subnet === clientIp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a given URL to ensure it is safe to redirect to.
|
||||||
|
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
|
||||||
|
*/
|
||||||
|
export function isSafeRedirectUrl(
|
||||||
|
rawUrl: string,
|
||||||
|
customDomain?: string,
|
||||||
|
): boolean {
|
||||||
|
if (!rawUrl) return false;
|
||||||
|
// 1. Relative paths within the same origin are safe
|
||||||
|
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
|
||||||
|
const cleanRoot = root.replace(/^\./, "");
|
||||||
|
|
||||||
|
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
|
||||||
|
if (
|
||||||
|
host === "localhost" ||
|
||||||
|
host === "atyg.org" ||
|
||||||
|
host.endsWith(".atyg.org") ||
|
||||||
|
host === cleanRoot ||
|
||||||
|
host.endsWith(`.${cleanRoot}`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Evaluates if the current user's capabilities satisfy the required scope.
|
* Evaluates if the current user's capabilities satisfy the required scope.
|
||||||
* If !auth.isAgent, primary sessions inherit full capabilities (returns true).
|
* If !auth.isAgent, primary sessions inherit full capabilities (returns true).
|
||||||
@ -102,3 +473,21 @@ export async function requireAdmin(c: Context, next: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClientIp(c: Context): string {
|
||||||
|
const realIp = c.req.header("x-real-ip");
|
||||||
|
if (realIp) {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
let forwardedFor = c.req.header("x-forwarded-for");
|
||||||
|
if (forwardedFor) {
|
||||||
|
if (forwardedFor.length > 256) {
|
||||||
|
forwardedFor = forwardedFor.substring(0, 256);
|
||||||
|
}
|
||||||
|
const parts = forwardedFor.split(",");
|
||||||
|
return parts[parts.length - 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "127.0.0.1";
|
||||||
|
}
|
||||||
|
|||||||
@ -1,152 +0,0 @@
|
|||||||
import type { Context } from "jsr:@hono/hono@4";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org)
|
|
||||||
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
|
|
||||||
*/
|
|
||||||
export function getCookieDomain(customRpId?: string): string | undefined {
|
|
||||||
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
|
||||||
if (envDomain) {
|
|
||||||
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
|
||||||
}
|
|
||||||
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
|
||||||
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const parts = targetId.split(".").filter(Boolean);
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return `.${parts.slice(-2).join(".")}`;
|
|
||||||
}
|
|
||||||
return `.${targetId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic fast path prefix matcher for dynamic bypasses
|
|
||||||
*/
|
|
||||||
export function isPathBypassed(
|
|
||||||
requestPath: string,
|
|
||||||
bypassPaths?: string[],
|
|
||||||
): boolean {
|
|
||||||
if (!bypassPaths || bypassPaths.length === 0) return false;
|
|
||||||
|
|
||||||
for (const pattern of bypassPaths) {
|
|
||||||
if (pattern === requestPath) return true;
|
|
||||||
if (pattern.endsWith("/*")) {
|
|
||||||
const prefix = pattern.slice(0, -2); // remove /*
|
|
||||||
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure native Deno bitwise CIDR matcher
|
|
||||||
*/
|
|
||||||
export function isIpAllowed(
|
|
||||||
clientIp: string,
|
|
||||||
allowedCidrs?: string[],
|
|
||||||
): boolean {
|
|
||||||
if (!allowedCidrs || allowedCidrs.length === 0) return false;
|
|
||||||
|
|
||||||
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
|
|
||||||
const parseIp4 = (ip: string) => {
|
|
||||||
const parts = ip.split(".");
|
|
||||||
if (parts.length !== 4) return null;
|
|
||||||
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
|
|
||||||
0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
|
|
||||||
// We extract the first IP (the original client)
|
|
||||||
const primaryIp = clientIp.split(",")[0].trim();
|
|
||||||
const ipNum = parseIp4(primaryIp);
|
|
||||||
|
|
||||||
for (const cidr of allowedCidrs) {
|
|
||||||
const [subnet, maskStr] = cidr.split("/");
|
|
||||||
if (!maskStr) {
|
|
||||||
if (subnet === primaryIp) return true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv4 CIDR matching
|
|
||||||
if (ipNum !== null && subnet.includes(".")) {
|
|
||||||
const subnetNum = parseIp4(subnet);
|
|
||||||
if (subnetNum !== null) {
|
|
||||||
const maskBits = parseInt(maskStr, 10);
|
|
||||||
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
|
|
||||||
const mask = maskBits === 0
|
|
||||||
? 0
|
|
||||||
: ((0xffffffff << (32 - maskBits)) >>> 0);
|
|
||||||
if ((ipNum & mask) === (subnetNum & mask)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
|
|
||||||
// Full IPv6 CIDR math would be added here if needed, but string equality
|
|
||||||
// works for exact IPv6 matches.
|
|
||||||
else if (subnet === clientIp) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a given URL to ensure it is safe to redirect to.
|
|
||||||
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
|
|
||||||
*/
|
|
||||||
export function isSafeRedirectUrl(
|
|
||||||
rawUrl: string,
|
|
||||||
customDomain?: string,
|
|
||||||
): boolean {
|
|
||||||
if (!rawUrl) return false;
|
|
||||||
// 1. Relative paths within the same origin are safe
|
|
||||||
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(rawUrl);
|
|
||||||
const host = parsed.hostname;
|
|
||||||
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
|
|
||||||
const cleanRoot = root.replace(/^\./, "");
|
|
||||||
|
|
||||||
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
|
|
||||||
if (
|
|
||||||
host === "localhost" ||
|
|
||||||
host === "atyg.org" ||
|
|
||||||
host.endsWith(".atyg.org") ||
|
|
||||||
host === cleanRoot ||
|
|
||||||
host.endsWith(`.${cleanRoot}`)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
|
|
||||||
*/
|
|
||||||
export function getClientIp(c: Context): string {
|
|
||||||
const realIp = c.req.header("x-real-ip");
|
|
||||||
if (realIp) {
|
|
||||||
return realIp.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
let forwardedFor = c.req.header("x-forwarded-for");
|
|
||||||
if (forwardedFor) {
|
|
||||||
if (forwardedFor.length > 256) {
|
|
||||||
forwardedFor = forwardedFor.substring(0, 256);
|
|
||||||
}
|
|
||||||
const parts = forwardedFor.split(",");
|
|
||||||
return parts[parts.length - 1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return "127.0.0.1";
|
|
||||||
}
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
import type { Context } from "jsr:@hono/hono@4";
|
|
||||||
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
|
||||||
import { sqlWrapper } from "./db.ts";
|
|
||||||
import { valkey } from "./valkey.ts";
|
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
|
||||||
userId: string;
|
|
||||||
sessionId: string;
|
|
||||||
username: string;
|
|
||||||
label?: string;
|
|
||||||
isAgent?: boolean;
|
|
||||||
customScopes?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppRecord {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
domain?: string;
|
|
||||||
is_public?: boolean;
|
|
||||||
bypass_paths?: string[];
|
|
||||||
allowed_cidrs?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts all session_id tokens from the Cookie and Authorization headers.
|
|
||||||
*/
|
|
||||||
export function extractAllSessionIds(c: Context): string[] {
|
|
||||||
const candidates: string[] = [];
|
|
||||||
|
|
||||||
// 1. Check Authorization: Bearer <token>
|
|
||||||
const authHeader = c.req.header("authorization") || "";
|
|
||||||
if (authHeader.startsWith("Bearer ")) {
|
|
||||||
const bearerToken = authHeader.substring(7).trim();
|
|
||||||
if (bearerToken) candidates.push(bearerToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check Cookie header
|
|
||||||
const cookieHeader = c.req.header("cookie") || "";
|
|
||||||
if (cookieHeader) {
|
|
||||||
const cookieMatches = [
|
|
||||||
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
|
|
||||||
]
|
|
||||||
.map((m) => decodeURIComponent(m[1].trim()))
|
|
||||||
.filter(Boolean);
|
|
||||||
candidates.push(...cookieMatches);
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuthenticatedUser(
|
|
||||||
c: Context,
|
|
||||||
): Promise<AuthenticatedUser | null> {
|
|
||||||
const sessionMatches = extractAllSessionIds(c);
|
|
||||||
if (sessionMatches.length === 0) return null;
|
|
||||||
|
|
||||||
// Iterate over each candidate session ID
|
|
||||||
for (let i = 0; i < sessionMatches.length; i++) {
|
|
||||||
const candidateId = sessionMatches[i];
|
|
||||||
|
|
||||||
// 1. Try Valkey cache
|
|
||||||
try {
|
|
||||||
const sessionDataStr = await valkey.get(candidateId);
|
|
||||||
if (sessionDataStr) {
|
|
||||||
const sessionData = JSON.parse(sessionDataStr);
|
|
||||||
if (sessionData && sessionData.uuid) {
|
|
||||||
if (i > 0) {
|
|
||||||
deleteCookie(c, "session_id", { path: "/" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: sessionData.uuid,
|
|
||||||
sessionId: candidateId,
|
|
||||||
username: sessionData.username || "",
|
|
||||||
label: sessionData.label,
|
|
||||||
isAgent: sessionData.isAgent,
|
|
||||||
customScopes: sessionData.customScopes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
// Valkey cache miss or connection hiccup - fallback to DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fallback to PostgreSQL sessions table
|
|
||||||
try {
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const session = await sqlWrapper.sql`
|
|
||||||
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
|
|
||||||
FROM sessions s
|
|
||||||
JOIN users u ON s.user_id = u.id
|
|
||||||
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
|
|
||||||
`.then((res: any) => res[0]);
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
const username = session.username || "";
|
|
||||||
// Repopulate Valkey in background
|
|
||||||
try {
|
|
||||||
const ttlSeconds = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor(
|
|
||||||
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await valkey.setex(
|
|
||||||
candidateId,
|
|
||||||
ttlSeconds,
|
|
||||||
JSON.stringify({
|
|
||||||
uuid: session.user_id,
|
|
||||||
username,
|
|
||||||
label: session.label,
|
|
||||||
isAgent: session.is_agent,
|
|
||||||
customScopes: session.custom_scopes,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (_e) {}
|
|
||||||
|
|
||||||
if (i > 0) {
|
|
||||||
deleteCookie(c, "session_id", { path: "/" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: session.user_id,
|
|
||||||
sessionId: candidateId,
|
|
||||||
username,
|
|
||||||
label: session.label,
|
|
||||||
isAgent: session.is_agent,
|
|
||||||
customScopes: session.custom_scopes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
// Continue to next candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<AppRecord | 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, domain, is_public, bypass_paths, allowed_cidrs
|
|
||||||
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, domain, is_public, bypass_paths, allowed_cidrs
|
|
||||||
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 as AppRecord;
|
|
||||||
}
|
|
||||||
} 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
|
|
||||||
* or global role, or is the bootstrap root user.
|
|
||||||
*/
|
|
||||||
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
|
|
||||||
const adminGrant = await sqlWrapper.sql`
|
|
||||||
SELECT g.id
|
|
||||||
FROM grants g
|
|
||||||
LEFT JOIN apps a ON g.app_id = a.id
|
|
||||||
WHERE g.user_id = ${userId}
|
|
||||||
AND g.role = 'admin'
|
|
||||||
AND (
|
|
||||||
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
|
|
||||||
OR a.name = 'Auth-Yes Management Console'
|
|
||||||
OR g.app_id IS NULL
|
|
||||||
)
|
|
||||||
`.then((res: any) => res[0]);
|
|
||||||
|
|
||||||
if (adminGrant) return true;
|
|
||||||
|
|
||||||
// Check 2: First registered user in system fallback
|
|
||||||
const firstUser = await sqlWrapper.sql`
|
|
||||||
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
|
|
||||||
`.then((res: any) => res[0]);
|
|
||||||
|
|
||||||
if (firstUser && firstUser.id === userId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Auth API] isGlobalAdmin error:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import type { Context } from "jsr:@hono/hono@4";
|
|
||||||
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
|
|
||||||
import {
|
|
||||||
type AuthenticatedUser,
|
|
||||||
getAuthenticatedUser,
|
|
||||||
getCookieDomain,
|
|
||||||
isGlobalAdmin,
|
|
||||||
isSessionAdmin,
|
|
||||||
} from "../server/auth-session.ts";
|
|
||||||
|
|
||||||
export interface UiAuthResult {
|
|
||||||
auth: AuthenticatedUser;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isSessionAdminRole: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to ensure a user is authenticated for UI routes.
|
|
||||||
* If not authenticated, clears cookies and redirects to /login.
|
|
||||||
* Returns the AuthenticatedUser and admin status if successful, or a Hono Response (redirect).
|
|
||||||
*/
|
|
||||||
export async function requireUiAuth(
|
|
||||||
c: Context,
|
|
||||||
): Promise<UiAuthResult | Response> {
|
|
||||||
const auth = await getAuthenticatedUser(c);
|
|
||||||
if (!auth) {
|
|
||||||
if (getCookie(c, "session_id")) {
|
|
||||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
||||||
deleteCookie(c, "session_id", {
|
|
||||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
||||||
path: "/",
|
|
||||||
}); // clear domain cookie
|
|
||||||
}
|
|
||||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
||||||
return c.redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
||||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
|
||||||
|
|
||||||
return { auth, isAdmin, isSessionAdminRole };
|
|
||||||
}
|
|
||||||
137
ui/db_queries.ts
137
ui/db_queries.ts
@ -1,137 +0,0 @@
|
|||||||
import { sql } from "../server/db.ts";
|
|
||||||
|
|
||||||
export async function getDashboardApps(userId: string, isAdmin: boolean) {
|
|
||||||
if (isAdmin) {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, name, description, domain, 'Admin' as role
|
|
||||||
FROM apps
|
|
||||||
WHERE domain IS NOT NULL
|
|
||||||
ORDER BY name ASC
|
|
||||||
` as any[];
|
|
||||||
} else {
|
|
||||||
return await sql`
|
|
||||||
SELECT a.id, a.name, a.description, a.domain, g.role
|
|
||||||
FROM apps a
|
|
||||||
JOIN grants g ON a.id = g.app_id
|
|
||||||
WHERE g.user_id = ${userId} AND a.domain IS NOT NULL
|
|
||||||
ORDER BY a.name ASC
|
|
||||||
` as any[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessionApps() {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, name, domain, spiffe_id
|
|
||||||
FROM apps
|
|
||||||
ORDER BY name ASC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserSessions(userId: string) {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
|
||||||
FROM sessions
|
|
||||||
WHERE user_id = ${userId} AND expires_at > NOW()
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserEventPasses(userId: string) {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at
|
|
||||||
FROM event_passes
|
|
||||||
WHERE created_by = ${userId} AND is_active = TRUE
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserPasskeys(userId: string) {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, credential_id, counter
|
|
||||||
FROM passkeys
|
|
||||||
WHERE user_id = ${userId}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminUsers() {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, username, display_name, account_status
|
|
||||||
FROM users
|
|
||||||
ORDER BY username ASC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminApps() {
|
|
||||||
return await sql`
|
|
||||||
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
|
||||||
COUNT(g.id) AS active_grants_count
|
|
||||||
FROM apps a
|
|
||||||
LEFT JOIN grants g ON a.id = g.app_id
|
|
||||||
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
|
||||||
ORDER BY a.created_at ASC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminRoles() {
|
|
||||||
return await sql`
|
|
||||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
|
||||||
a.name AS app_name
|
|
||||||
FROM roles r
|
|
||||||
LEFT JOIN apps a ON r.app_id = a.id
|
|
||||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminInvites() {
|
|
||||||
return await sql`
|
|
||||||
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
|
||||||
a.name AS app_name, a.id AS app_id,
|
|
||||||
u.username AS used_by_username
|
|
||||||
FROM invites i
|
|
||||||
LEFT JOIN apps a ON i.app_id = a.id
|
|
||||||
LEFT JOIN users u ON i.used_by = u.id
|
|
||||||
ORDER BY i.created_at DESC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllRoles() {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAaguidAllowlist() {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, aaguid, description, created_at
|
|
||||||
FROM aaguid_allowlist
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminUserDetails(targetUserId: string) {
|
|
||||||
return await sql`
|
|
||||||
SELECT id, username, display_name, account_status
|
|
||||||
FROM users
|
|
||||||
WHERE id = ${targetUserId}
|
|
||||||
`.then((res) => res[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserGrants(targetUserId: string) {
|
|
||||||
return await sql`
|
|
||||||
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
|
||||||
FROM grants g
|
|
||||||
JOIN apps a ON g.app_id = a.id
|
|
||||||
WHERE g.user_id = ${targetUserId}
|
|
||||||
ORDER BY a.name ASC
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminAuditLogs() {
|
|
||||||
return await sql`
|
|
||||||
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
|
||||||
FROM audit_records a
|
|
||||||
LEFT JOIN users u ON a.user_id = u.id
|
|
||||||
ORDER BY a.created_at DESC
|
|
||||||
LIMIT 100
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
343
ui/mod.ts
343
ui/mod.ts
@ -1,33 +1,17 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
||||||
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import { sql } from "../server/db.ts";
|
import { sql } from "../server/db.ts";
|
||||||
import { valkey } from "../server/valkey.ts";
|
import { valkey } from "../server/valkey.ts";
|
||||||
import {
|
import {
|
||||||
extractAllSessionIds,
|
extractAllSessionIds,
|
||||||
getAuthenticatedUser,
|
getAuthenticatedUser,
|
||||||
getCookieDomain,
|
getCookieDomain,
|
||||||
|
isGlobalAdmin,
|
||||||
isSafeRedirectUrl,
|
isSafeRedirectUrl,
|
||||||
|
isSessionAdmin,
|
||||||
} from "../server/auth-session.ts";
|
} from "../server/auth-session.ts";
|
||||||
import { auditWrapper } from "../server/audit.ts";
|
import { auditWrapper } from "../server/audit.ts";
|
||||||
import { requireUiAuth } from "./auth_checks.ts";
|
|
||||||
import {
|
|
||||||
getAaguidAllowlist,
|
|
||||||
getAdminApps,
|
|
||||||
getAdminAuditLogs,
|
|
||||||
getAdminInvites,
|
|
||||||
getAdminRoles,
|
|
||||||
getAdminUserDetails,
|
|
||||||
getAdminUsers,
|
|
||||||
getAllRoles,
|
|
||||||
getDashboardApps,
|
|
||||||
getSessionApps,
|
|
||||||
getUserEventPasses,
|
|
||||||
getUserGrants,
|
|
||||||
getUserPasskeys,
|
|
||||||
getUserSessions,
|
|
||||||
} from "./db_queries.ts";
|
|
||||||
|
|
||||||
import { LoginPage } from "./components/LoginPage.tsx";
|
import { LoginPage } from "./components/LoginPage.tsx";
|
||||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||||
import { SessionsPage } from "./components/SessionsPage.tsx";
|
import { SessionsPage } from "./components/SessionsPage.tsx";
|
||||||
@ -147,23 +131,77 @@ uiApp.get("/register", (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/dashboard", async (c) => {
|
uiApp.get("/dashboard", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { auth, isAdmin } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
const apps = await getDashboardApps(auth.userId, isAdmin);
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
|
||||||
|
let apps = [];
|
||||||
|
if (isAdmin) {
|
||||||
|
apps = await sql`
|
||||||
|
SELECT id, name, description, domain, 'Admin' as role
|
||||||
|
FROM apps
|
||||||
|
WHERE domain IS NOT NULL
|
||||||
|
ORDER BY name ASC
|
||||||
|
` as any[];
|
||||||
|
} else {
|
||||||
|
apps = await sql`
|
||||||
|
SELECT a.id, a.name, a.description, a.domain, g.role
|
||||||
|
FROM apps a
|
||||||
|
JOIN grants g ON a.id = g.app_id
|
||||||
|
WHERE g.user_id = ${auth.userId} AND a.domain IS NOT NULL
|
||||||
|
ORDER BY a.name ASC
|
||||||
|
` as any[];
|
||||||
|
}
|
||||||
|
|
||||||
return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
|
return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/dashboard/sessions", async (c) => {
|
uiApp.get("/dashboard/sessions", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { auth, isAdmin } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
const apps = await getSessionApps();
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
const sessions = await getUserSessions(auth.userId);
|
|
||||||
const eventPasses = await getUserEventPasses(auth.userId);
|
const apps = await sql`
|
||||||
|
SELECT id, name, domain, spiffe_id
|
||||||
|
FROM apps
|
||||||
|
ORDER BY name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sessions = await sql`
|
||||||
|
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE user_id = ${auth.userId} AND expires_at > NOW()
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const eventPasses = await sql`
|
||||||
|
SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at
|
||||||
|
FROM event_passes
|
||||||
|
WHERE created_by = ${auth.userId} AND is_active = TRUE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
SessionsPage({
|
SessionsPage({
|
||||||
@ -177,11 +215,26 @@ uiApp.get("/dashboard/sessions", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/dashboard/passkeys", async (c) => {
|
uiApp.get("/dashboard/passkeys", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { auth, isAdmin } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(auth.userId);
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
|
||||||
|
const passkeys = await sql`
|
||||||
|
SELECT id, credential_id, counter
|
||||||
|
FROM passkeys
|
||||||
|
WHERE user_id = ${auth.userId}
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(PasskeysPage({ passkeys, isAdmin }));
|
return c.html(PasskeysPage({ passkeys, isAdmin }));
|
||||||
});
|
});
|
||||||
@ -192,95 +245,229 @@ uiApp.get("/admin", (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/users", async (c) => {
|
uiApp.get("/admin/users", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin, isSessionAdminRole } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||||
if (!isAdmin || !isSessionAdminRole) {
|
if (!isAdmin || !isSessionAdminRole) {
|
||||||
return c.redirect("/dashboard", 302);
|
return c.redirect("/dashboard", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await getAdminUsers();
|
const users = await sql`
|
||||||
|
SELECT id, username, display_name, account_status
|
||||||
|
FROM users
|
||||||
|
ORDER BY username ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(AdminUsersPage({ users }));
|
return c.html(AdminUsersPage({ users }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/apps", async (c) => {
|
uiApp.get("/admin/apps", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin, isSessionAdminRole } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||||
if (!isAdmin || !isSessionAdminRole) {
|
if (!isAdmin || !isSessionAdminRole) {
|
||||||
return c.redirect("/dashboard", 302);
|
return c.redirect("/dashboard", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apps = await getAdminApps();
|
const apps = await sql`
|
||||||
|
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
||||||
|
COUNT(g.id) AS active_grants_count
|
||||||
|
FROM apps a
|
||||||
|
LEFT JOIN grants g ON a.id = g.app_id
|
||||||
|
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
||||||
|
ORDER BY a.created_at ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(AdminAppsPage({ apps }));
|
return c.html(AdminAppsPage({ apps }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/roles", async (c) => {
|
uiApp.get("/admin/roles", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin, isSessionAdminRole } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||||
if (!isAdmin || !isSessionAdminRole) {
|
if (!isAdmin || !isSessionAdminRole) {
|
||||||
return c.redirect("/dashboard", 302);
|
return c.redirect("/dashboard", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = await getAdminRoles();
|
const roles = await sql`
|
||||||
const apps = await getSessionApps();
|
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||||
|
a.name AS app_name
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN apps a ON r.app_id = a.id
|
||||||
|
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const apps = await sql`
|
||||||
|
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(AdminRolesPage({ roles, apps }));
|
return c.html(AdminRolesPage({ roles, apps }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/invites", async (c) => {
|
uiApp.get("/admin/invites", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin, isSessionAdminRole } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||||
if (!isAdmin || !isSessionAdminRole) {
|
if (!isAdmin || !isSessionAdminRole) {
|
||||||
return c.redirect("/dashboard", 302);
|
return c.redirect("/dashboard", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invites = await getAdminInvites();
|
const invites = await sql`
|
||||||
const apps = await getSessionApps();
|
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
||||||
const allRoles = await getAllRoles();
|
a.name AS app_name, a.id AS app_id,
|
||||||
|
u.username AS used_by_username
|
||||||
|
FROM invites i
|
||||||
|
LEFT JOIN apps a ON i.app_id = a.id
|
||||||
|
LEFT JOIN users u ON i.used_by = u.id
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const apps = await sql`
|
||||||
|
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const allRoles = await sql`
|
||||||
|
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(AdminInvitesPage({ invites, apps, allRoles }));
|
return c.html(AdminInvitesPage({ invites, apps, allRoles }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/aaguid", async (c) => {
|
uiApp.get("/admin/aaguid", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return c.redirect("/dashboard");
|
return c.redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowlist = await getAaguidAllowlist();
|
const allowlist = await sql`
|
||||||
|
SELECT id, aaguid, description, created_at
|
||||||
|
FROM aaguid_allowlist
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(AAGUIDPage({ allowlist }));
|
return c.html(AAGUIDPage({ allowlist }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/users/:id", async (c) => {
|
uiApp.get("/admin/users/:id", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin, isSessionAdminRole } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||||
if (!isAdmin || !isSessionAdminRole) {
|
if (!isAdmin || !isSessionAdminRole) {
|
||||||
return c.redirect("/admin/users", 302);
|
return c.redirect("/admin/users", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUserId = c.req.param("id");
|
const targetUserId = c.req.param("id");
|
||||||
const user = await getAdminUserDetails(targetUserId);
|
const user = await sql`
|
||||||
|
SELECT id, username, display_name, account_status
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${targetUserId}
|
||||||
|
`.then((res) => res[0]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return c.redirect("/admin/users");
|
return c.redirect("/admin/users");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = await getUserSessions(targetUserId);
|
const sessions = await sql`
|
||||||
const passkeys = await getUserPasskeys(targetUserId);
|
SELECT id, created_at, expires_at
|
||||||
const grants = await getUserGrants(targetUserId);
|
FROM sessions
|
||||||
const allApps = await getSessionApps();
|
WHERE user_id = ${targetUserId} AND expires_at > NOW()
|
||||||
const allRoles = await getAllRoles();
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const passkeys = await sql`
|
||||||
|
SELECT id, credential_id, counter
|
||||||
|
FROM passkeys
|
||||||
|
WHERE user_id = ${targetUserId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const grants = await sql`
|
||||||
|
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
||||||
|
FROM grants g
|
||||||
|
JOIN apps a ON g.app_id = a.id
|
||||||
|
WHERE g.user_id = ${targetUserId}
|
||||||
|
ORDER BY a.name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const allApps = await sql`
|
||||||
|
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const allRoles = await sql`
|
||||||
|
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
AdminUserDetailsPage({
|
AdminUserDetailsPage({
|
||||||
@ -295,15 +482,33 @@ uiApp.get("/admin/users/:id", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/admin/audit-logs", async (c) => {
|
uiApp.get("/admin/audit-logs", async (c) => {
|
||||||
const authRes = await requireUiAuth(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (authRes instanceof Response) return authRes;
|
if (!auth) {
|
||||||
const { isAdmin, isSessionAdminRole } = authRes;
|
if (getCookie(c, "session_id")) {
|
||||||
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||||
|
deleteCookie(c, "session_id", {
|
||||||
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||||
|
path: "/",
|
||||||
|
}); // clear domain cookie
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
return c.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||||
|
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||||
if (!isAdmin || !isSessionAdminRole) {
|
if (!isAdmin || !isSessionAdminRole) {
|
||||||
return c.redirect("/dashboard", 302);
|
return c.redirect("/dashboard", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await getAdminAuditLogs();
|
const logs = await sql`
|
||||||
|
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
||||||
|
FROM audit_records a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
|
||||||
return c.html(AuditLogPage({ logs }));
|
return c.html(AuditLogPage({ logs }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user