Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules/
package-lock.json
.env
.env.local
.DS_Store
*.log
dist/
build/
out/
.vscode/settings.json
18 changes: 18 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "kloak",
"version": "0.1.0",
"type": "module",
"description": "Opinionated auth appliance for Keycloak deployments",
"engines": {
"node": ">=25.0.0"
},
"scripts": {
"test": "node --experimental-transform-types --test src/**/*.test.ts",
"dev": "PORT=3000 node --experimental-transform-types src/admin-ui/cli.ts",
"migrate": "node --experimental-transform-types src/database/migrate.ts"
},
Comment on lines +9 to +13
"dependencies": {
"postgres": "^3.4.3"
},
"keywords": ["keycloak", "auth", "deployment", "infrastructure"]
}
21 changes: 19 additions & 2 deletions src/admin-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,32 @@ This package provides the HTTP API server for Kloak deployments.

## Starting the Server

### In-memory mode (default, for local development without persistence)

```bash
node --experimental-transform-types src/admin-ui/cli.ts
npm run dev
# or
PORT=3000 node --experimental-transform-types src/admin-ui/cli.ts
```

### PostgreSQL mode (persistent deployments)

```bash
# Run migrations and start server
DATABASE_URL=postgresql://user:password@localhost/kloak npm run dev

# Or manually:
npm run migrate
DATABASE_URL=postgresql://user:password@localhost/kloak node --experimental-transform-types src/admin-ui/cli.ts
```

Set port and host via environment variables:
```bash
PORT=3000 HOST=localhost node --experimental-transform-types src/admin-ui/cli.ts
PORT=3000 HOST=0.0.0.0 DATABASE_URL=postgresql://... npm run dev
```

See [src/database/README.md](../database/README.md) for PostgreSQL setup instructions.

## HTTP API

### POST /deployments
Expand Down
16 changes: 15 additions & 1 deletion src/admin-ui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {createServer} from 'node:http';

import {CoreService} from '../core/index.ts';
import {InMemoryDeploymentRepository} from '../core/index.ts';
import {SqlDeploymentRepository, runMigrations} from '../database/index.ts';
import type {HttpMethod, HttpRequest, HttpResponse} from './handler.ts';
import {AdminHandler} from './handler.ts';

Expand Down Expand Up @@ -66,7 +67,20 @@ async function readBody(req: NodeJS.ReadableStream): Promise<unknown> {
}

export async function startServer(config: ServerConfig): Promise<Server> {
const repository = new InMemoryDeploymentRepository();
// Initialize repository (PostgreSQL if DATABASE_URL is set, otherwise in-memory)
let repository;
const databaseUrl = process.env.DATABASE_URL;

if (databaseUrl) {
console.log('Initializing PostgreSQL repository...');
await runMigrations(databaseUrl);
repository = new SqlDeploymentRepository({connectionString: databaseUrl});
console.log('✓ PostgreSQL repository initialized');
} else {
console.log('Using in-memory repository (set DATABASE_URL to use PostgreSQL)');
repository = new InMemoryDeploymentRepository();
}

const core = new CoreService(repository);
const handler = new AdminHandler(core);

Expand Down
52 changes: 52 additions & 0 deletions src/database/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# database

PostgreSQL-backed persistence layer for Kloak deployments.

## Setup

### Local development

```bash
# Install postgres locally or use Docker
docker run --name kloak-postgres -e POSTGRES_DB=kloak -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16

# Create .env file
echo "DATABASE_URL=postgresql://postgres:dev@localhost/kloak" > .env

# Run migrations
npm run migrate

# Start server with Postgres
source .env && npm run dev
```

### Cloud deployment (fly.io)

```bash
# Create Postgres database
fly postgres create --name kloak-db

# Attach to app
fly postgres attach kloak-db --app kloak

# Migrations run automatically on app start (or manually with `fly ssh console` + `npm run migrate`)
```

## Environment Variables

- `DATABASE_URL` — PostgreSQL connection string (required for Postgres mode; omit to use in-memory)

## Schema

Tables:
- `deployments` — Deployment records with status and target config
- `deployment_versions` — Immutable versions of each deployment
- `desired_state_snapshots` — Auth config snapshots for each version
- `reconciliation_runs` — Drift detection and repair attempts
- `audit_events` — Operator-visible action log

All tables include indexes for query performance.

## TypeScript Types

The repository implements the `DeploymentRepository` interface from `src/core/index.ts`, so it's a drop-in replacement for `InMemoryDeploymentRepository`.
3 changes: 3 additions & 0 deletions src/database/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {SqlDeploymentRepository} from './sql-repository.ts';
export type {SqlRepositoryConfig} from './sql-repository.ts';
export {runMigrations} from './migrate.ts';
41 changes: 41 additions & 0 deletions src/database/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {dirname, join} from 'node:path';
import postgres from 'postgres';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

export async function runMigrations(connectionString: string): Promise<void> {
const sql = postgres(connectionString);

try {
// Read schema file
const schemaPath = join(__dirname, 'schema.sql');
const schema = readFileSync(schemaPath, 'utf-8');

// Split by semicolon and filter empty statements
const statements = schema
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);

// Execute each statement
for (const statement of statements) {
await sql.unsafe(statement);
}

console.log('✓ Database schema initialized');
} finally {
await sql.end();
}
}

// If run directly, migrate the database
if (import.meta.url === `file://${process.argv[1]}`) {
const connectionString = process.env.DATABASE_URL || 'postgresql://localhost/kloak';
runMigrations(connectionString).catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});
}
Comment on lines +35 to +41
53 changes: 53 additions & 0 deletions src/database/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- Core deployment tables for Kloak

CREATE TABLE IF NOT EXISTS deployments (
id UUID PRIMARY KEY,
customer_id TEXT NOT NULL,
name TEXT NOT NULL,
target JSONB NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE(customer_id, name)
);

CREATE TABLE IF NOT EXISTS deployment_versions (
id UUID PRIMARY KEY,
deployment_id UUID NOT NULL REFERENCES deployments(id),
number INTEGER NOT NULL,
created_by TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE(deployment_id, number)
);

CREATE TABLE IF NOT EXISTS desired_state_snapshots (
id UUID PRIMARY KEY,
version_id UUID NOT NULL,
keycloak_config JSONB NOT NULL,
auth_providers JSONB NOT NULL,
Comment on lines +24 to +28
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);

CREATE TABLE IF NOT EXISTS reconciliation_runs (
id UUID PRIMARY KEY,
deployment_id UUID NOT NULL REFERENCES deployments(id),
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE,
status TEXT NOT NULL,
drift_findings JSONB,
error TEXT
);

CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY,
deployment_id UUID NOT NULL REFERENCES deployments(id),
event_type TEXT NOT NULL,
data JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_deployments_customer ON deployments(customer_id);
CREATE INDEX IF NOT EXISTS idx_versions_deployment ON deployment_versions(deployment_id);
CREATE INDEX IF NOT EXISTS idx_reconciliation_deployment ON reconciliation_runs(deployment_id);
CREATE INDEX IF NOT EXISTS idx_audit_deployment ON audit_events(deployment_id);
Loading