312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import postgres from "npm:postgres@3";
|
|
|
|
// Evaluate environment variables dynamically to prevent crash during test imports
|
|
const host = Deno.env.get("POSTGRES_HOST") ||
|
|
(import.meta.main ? "" : "localhost");
|
|
const user = Deno.env.get("POSTGRES_USER") ||
|
|
(import.meta.main ? "" : "postgres");
|
|
const password = Deno.env.get("POSTGRES_PASSWORD") ||
|
|
(import.meta.main ? "" : "postgres");
|
|
const db = Deno.env.get("POSTGRES_DB") || (import.meta.main ? "" : "postgres");
|
|
const port = Deno.env.get("POSTGRES_PORT") || "5432";
|
|
|
|
if (import.meta.main && (!host || !user || !password || !db)) {
|
|
throw new Error(
|
|
"Missing critical database environment variables. Required: POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.",
|
|
);
|
|
}
|
|
|
|
const connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`;
|
|
|
|
// Export it as let so we can override it in tests
|
|
export let sql = postgres(connectionString);
|
|
|
|
/**
|
|
* SIDE EFFECT: Initializes the database schema.
|
|
*/
|
|
export async function initDb(): Promise<void> {
|
|
console.log("[Auth DB] Initializing central identity database schema...");
|
|
|
|
// We ensure new users are 'pending' to satisfy Use Case 3 (Manual state machine activation)
|
|
// If the table exists we will attempt to alter the default.
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
username TEXT UNIQUE NOT NULL,
|
|
display_name TEXT,
|
|
account_status TEXT DEFAULT 'pending',
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
try {
|
|
await sql`ALTER TABLE users ALTER COLUMN account_status SET DEFAULT 'pending'`;
|
|
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`;
|
|
} catch {
|
|
// Ignore if unsupported
|
|
}
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS apps (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
spiffe_id VARCHAR(255) UNIQUE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
// Ensure app_secret column exists for API validation mapping
|
|
try {
|
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS app_secret TEXT UNIQUE`;
|
|
} catch {
|
|
// Soft ignore if column already exists
|
|
// 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
|
|
}
|
|
|
|
// Tier 1 Ingress Control
|
|
try {
|
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE`;
|
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS bypass_paths TEXT[] DEFAULT '{}'`;
|
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS allowed_cidrs TEXT[] DEFAULT '{}'`;
|
|
} catch {
|
|
// Soft ignore if columns already exist
|
|
}
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS roles (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(name, app_id)
|
|
);
|
|
`;
|
|
|
|
// Seed standard global roles if table is empty
|
|
try {
|
|
const existingRoles = await sql`SELECT count(*)::int as count FROM roles`
|
|
.then((res) => res[0]?.count || 0);
|
|
if (existingRoles === 0) {
|
|
await sql`
|
|
INSERT INTO roles (name, description, app_id) VALUES
|
|
('admin', 'Full administrative access across all management capabilities', NULL),
|
|
('editor', 'Read and write access with permissions to modify records', NULL),
|
|
('operator', 'Operational execution access for runtime tasks', NULL),
|
|
('viewer', 'Read-only access across application telemetry and views', NULL)
|
|
ON CONFLICT DO NOTHING
|
|
`;
|
|
}
|
|
} catch {
|
|
// Ignore seed errors on race conditions
|
|
}
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS grants (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(user_id, app_id)
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS invites (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
code TEXT UNIQUE NOT NULL,
|
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
max_uses INT DEFAULT 1,
|
|
uses_count INT DEFAULT 0,
|
|
auto_activate BOOLEAN DEFAULT TRUE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
used_by UUID REFERENCES users(id) ON DELETE SET NULL
|
|
);
|
|
`;
|
|
|
|
// Migrations for existing invites table
|
|
try {
|
|
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS max_uses INT DEFAULT 1`;
|
|
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS uses_count INT DEFAULT 0`;
|
|
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS auto_activate BOOLEAN DEFAULT TRUE`;
|
|
} catch {
|
|
// Ignore migration column exists
|
|
}
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS invite_redemptions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS aaguid_allowlist (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
aaguid UUID UNIQUE NOT NULL,
|
|
description TEXT,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS recovery_links (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
code TEXT UNIQUE NOT NULL,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
used_at TIMESTAMP WITH TIME ZONE
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS recovery_shares (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
server_share TEXT NOT NULL,
|
|
pin_hash TEXT NOT NULL,
|
|
attempts_count INT DEFAULT 0,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS audit_records (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
action TEXT NOT NULL,
|
|
resource TEXT,
|
|
details JSONB,
|
|
ip_address TEXT,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
leaf_hash TEXT
|
|
);
|
|
`;
|
|
|
|
try {
|
|
await sql`ALTER TABLE audit_records ADD COLUMN IF NOT EXISTS leaf_hash TEXT`;
|
|
} catch {
|
|
// Ignore migration column exists
|
|
}
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS audit_sths (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tree_size BIGINT NOT NULL,
|
|
root_hash TEXT NOT NULL,
|
|
signature TEXT NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS passkeys (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
credential_id TEXT UNIQUE NOT NULL,
|
|
public_key TEXT NOT NULL,
|
|
counter BIGINT NOT NULL,
|
|
prf_enabled BOOLEAN DEFAULT FALSE,
|
|
prf_salt TEXT
|
|
);
|
|
`;
|
|
|
|
// Ensure prf columns exist
|
|
try {
|
|
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_enabled BOOLEAN DEFAULT FALSE`;
|
|
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_salt TEXT`;
|
|
} catch {
|
|
// Ignore migration column exists
|
|
}
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
);
|
|
`;
|
|
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS hwk_keys (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
fingerprint TEXT UNIQUE NOT NULL,
|
|
public_key JSONB NOT NULL,
|
|
name TEXT NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`;
|
|
|
|
// Seed ed-droid app record with spiffe_id
|
|
await sql`
|
|
INSERT INTO apps (name, spiffe_id)
|
|
VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend')
|
|
ON CONFLICT (spiffe_id) DO NOTHING
|
|
`;
|
|
|
|
// Seed the Central Auth-Yes Management App for global administration
|
|
await sql`
|
|
INSERT INTO apps (name, spiffe_id)
|
|
VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management')
|
|
ON CONFLICT (spiffe_id) DO NOTHING
|
|
`;
|
|
|
|
// Seed initial bootstrap admin invite if no users exist in the database
|
|
try {
|
|
const userCount = await sql`SELECT count(*)::int as count FROM users`.then(
|
|
(res) => res[0]?.count || 0,
|
|
);
|
|
if (userCount === 0) {
|
|
const inviteCount = await sql`
|
|
SELECT count(*)::int as count FROM invites
|
|
WHERE role = 'admin' AND expires_at > NOW()
|
|
`.then((res) => res[0]?.count || 0);
|
|
|
|
if (inviteCount === 0) {
|
|
const bootstrapCode = Deno.env.get("BOOTSTRAP_INVITE_CODE") ||
|
|
"bootstrap-admin";
|
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
|
await sql`
|
|
INSERT INTO invites (code, app_id, role, max_uses, uses_count, auto_activate, expires_at)
|
|
VALUES (${bootstrapCode}, NULL, 'admin', 1, 0, TRUE, ${expiresAt})
|
|
ON CONFLICT (code) DO NOTHING
|
|
`;
|
|
console.log(
|
|
`[Auth DB] Initial bootstrap admin invite code seeded: '${bootstrapCode}'`,
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("[Auth DB] Bootstrap invite check skipped:", err);
|
|
}
|
|
|
|
console.log("[Auth DB] Central identity database schema initialized.");
|
|
}
|
|
|
|
export const sqlWrapper = {
|
|
get sql() {
|
|
return sql;
|
|
},
|
|
set sql(val: any) {
|
|
sql = val;
|
|
},
|
|
};
|