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
82 changes: 82 additions & 0 deletions packages/api/drizzle/0017_settings_defaults.sql

Large diffs are not rendered by default.

135 changes: 135 additions & 0 deletions packages/api/drizzle/0018_default_clients_and_org_rbac.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
INSERT INTO "permissions" ("key", "description")
VALUES
('darkauth.users:read', 'Allows searching and reading users from the user directory endpoints'),
('darkauth.org:manage', 'Allows management of organization members, roles, and invites')
ON CONFLICT ("key") DO UPDATE
SET
"description" = EXCLUDED."description";
--> statement-breakpoint
INSERT INTO "roles" ("key", "name", "description", "system", "created_at", "updated_at")
VALUES
('org_admin', 'Organization Admin', 'Can manage organization members, roles, and invitations', true, now(), now()),
('member', 'Member', 'Default organization member role', true, now(), now())
ON CONFLICT ("key") DO UPDATE
SET
"name" = EXCLUDED."name",
"description" = EXCLUDED."description",
"system" = EXCLUDED."system",
"updated_at" = now();
--> statement-breakpoint
INSERT INTO "role_permissions" ("role_id", "permission_key")
SELECT r."id", p."key"
FROM "roles" r
JOIN "permissions" p ON p."key" IN ('darkauth.org:manage', 'darkauth.users:read')
WHERE r."key" = 'org_admin'
ON CONFLICT DO NOTHING;
--> statement-breakpoint
INSERT INTO "organization_member_roles" ("organization_member_id", "role_id")
SELECT om."id", r."id"
FROM "organization_members" om
JOIN "roles" r ON r."key" = 'member'
LEFT JOIN "organization_member_roles" omr ON omr."organization_member_id" = om."id"
WHERE omr."organization_member_id" IS NULL
ON CONFLICT DO NOTHING;
--> statement-breakpoint
INSERT INTO "clients" (
"client_id",
"name",
"type",
"token_endpoint_auth_method",
"client_secret_enc",
"require_pkce",
"zk_delivery",
"zk_required",
"allowed_jwe_algs",
"allowed_jwe_encs",
"redirect_uris",
"post_logout_redirect_uris",
"grant_types",
"response_types",
"scopes",
"allowed_zk_origins",
"created_at",
"updated_at"
)
VALUES
(
'user',
'User Portal',
'public',
'none',
NULL,
true,
'none',
false,
ARRAY[]::text[],
ARRAY[]::text[],
ARRAY['http://localhost:9080/callback']::text[],
ARRAY['http://localhost:9080']::text[],
ARRAY['authorization_code', 'refresh_token']::text[],
ARRAY['code']::text[],
ARRAY[
'{"key":"openid","description":"Authenticate you"}',
'{"key":"profile","description":"Access your profile information"}',
'{"key":"email","description":"Access your email address"}'
]::text[],
ARRAY['http://localhost:9080']::text[],
now(),
now()
),
(
'demo-public-client',
'Demo Public Client',
'public',
'none',
NULL,
true,
'fragment-jwe',
true,
ARRAY['ECDH-ES']::text[],
ARRAY['A256GCM']::text[],
ARRAY[
'http://localhost:9092/',
'http://localhost:9092/callback',
'http://localhost:3000/',
'http://localhost:3000/callback',
'https://app.example.com/',
'https://app.example.com/callback'
]::text[],
ARRAY['http://localhost:9092/', 'http://localhost:3000', 'https://app.example.com']::text[],
ARRAY['authorization_code', 'refresh_token']::text[],
ARRAY['code']::text[],
ARRAY[
'{"key":"openid","description":"Authenticate you"}',
'{"key":"profile","description":"Access your profile information"}',
'{"key":"email","description":"Access your email address"}'
]::text[],
ARRAY['http://localhost:9092', 'http://localhost:3000', 'https://app.example.com']::text[],
now(),
now()
),
(
'demo-confidential-client',
'Demo Confidential Client',
'confidential',
'client_secret_basic',
NULL,
false,
'none',
false,
ARRAY[]::text[],
ARRAY[]::text[],
ARRAY['http://localhost:4000/callback', 'https://support.example.com/callback']::text[],
ARRAY['http://localhost:4000', 'https://support.example.com']::text[],
ARRAY['authorization_code', 'refresh_token', 'client_credentials']::text[],
ARRAY['code']::text[],
ARRAY[
'{"key":"openid","description":"Authenticate you"}',
'{"key":"profile","description":"Access your profile information"}',
'{"key":"darkauth.users:read","description":"Search and read users from the directory"}'
]::text[],
ARRAY[]::text[],
now(),
now()
)
ON CONFLICT ("client_id") DO NOTHING;
14 changes: 14 additions & 0 deletions packages/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@
"when": 1772445600000,
"tag": "0016_email_verification",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1772447400000,
"tag": "0017_settings_defaults",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1772449200000,
"tag": "0018_default_clients_and_org_rbac",
"breakpoints": true
}
]
}
54 changes: 35 additions & 19 deletions packages/api/scripts/install.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
#!/usr/bin/env node

/**
* Seeding policy:
* - Production/default bootstrap data (for example settings, RBAC, default clients) must be owned by database migrations.
* - Runtime install logic should only write install-specific values that are unique to this instance.
* - Code-based seeding is reserved for test/dev/sample/demo data.
*/
import { eq } from "drizzle-orm";
import { createContext } from "../src/context/createContext.ts";
import { adminUsers, settings } from "../src/db/schema.ts";
import { seedDefaultOrganizationRbac } from "../src/models/install.ts";
import { adminUsers, clients, settings } from "../src/db/schema.ts";
import { generateEdDSAKeyPair, storeKeyPair } from "../src/services/jwks.ts";
import { createKekService, generateKdfParams } from "../src/services/kek.ts";
import { isSystemInitialized, markSystemInitialized, seedDefaultSettings } from "../src/services/settings.ts";
import { seedDefaultClients } from "../src/models/install.ts";
import { isSystemInitialized, markSystemInitialized, setSetting } from "../src/services/settings.ts";
import type { Config, KdfParams } from "../src/types.ts";
import { generateRandomString } from "../src/utils/crypto.ts";
import fs from "node:fs";
Expand Down Expand Up @@ -135,13 +140,9 @@ async function install() {
updatedAt: new Date(),
});

console.log("3. Seeding default settings...");
await seedDefaultSettings(
context,
config.issuer,
config.publicOrigin,
config.rpId,
);
await setSetting(context, "issuer", config.issuer);
await setSetting(context, "public_origin", config.publicOrigin);
await setSetting(context, "rp_id", config.rpId);

await context.db.insert(settings).values({
key: "ui_user",
Expand Down Expand Up @@ -289,13 +290,28 @@ async function install() {
Buffer.from(demoConfidentialClientSecret),
);
}

await seedDefaultClients(context, demoConfidentialSecretEnc, config.publicOrigin);

console.log("6. Seeding default group...");
await seedDefaultOrganizationRbac(context);

console.log("7. Creating default admin user...");
// Seeding is migration-owned. Install only writes runtime-specific values.
if (demoConfidentialSecretEnc) {
await context.db
.update(clients)
.set({
clientSecretEnc: demoConfidentialSecretEnc,
updatedAt: new Date(),
})
.where(eq(clients.clientId, "demo-confidential-client"));
}
const normalizedOrigin = config.publicOrigin.replace(/\/+$/, "");
await context.db
.update(clients)
.set({
redirectUris: [`${normalizedOrigin}/callback`],
postLogoutRedirectUris: [normalizedOrigin],
allowedZkOrigins: [normalizedOrigin],
updatedAt: new Date(),
})
.where(eq(clients.clientId, "user"));

console.log("6. Creating default admin user...");
const adminEmail = "admin@example.com";
const adminName = "System Administrator";

Expand All @@ -306,7 +322,7 @@ async function install() {
createdAt: new Date(),
});

console.log("8. Marking system as initialized...");
console.log("7. Marking system as initialized...");
await markSystemInitialized(context);

console.log(
Expand Down
44 changes: 24 additions & 20 deletions packages/api/src/controllers/install/postInstallComplete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { eq } from "drizzle-orm";
import { z } from "zod/v4";

import { clients } from "../../db/schema.ts";
import {
AlreadyInitializedError,
ExpiredInstallTokenError,
Expand All @@ -10,12 +12,7 @@ import {
import { genericErrors } from "../../http/openapi-helpers.ts";
import { generateEdDSAKeyPair, storeKeyPair } from "../../services/jwks.ts";
import { ensureKekService, generateKdfParams } from "../../services/kek.ts";
import {
isSystemInitialized,
markSystemInitialized,
seedDefaultSettings,
setSetting,
} from "../../services/settings.ts";
import { isSystemInitialized, markSystemInitialized, setSetting } from "../../services/settings.ts";
import type { Context, ControllerSchema } from "../../types.ts";
import { withAudit } from "../../utils/auditWrapper.ts";
import { generateRandomString } from "../../utils/crypto.ts";
Expand Down Expand Up @@ -113,15 +110,11 @@ async function _postInstallComplete(
throw new Error("No database available for installation");
}

context.logger.info("[install:post] Seeding default settings");
const tempContextDb = { ...context, db } as Context;
await seedDefaultSettings(
tempContextDb,
context.config.issuer,
context.config.publicOrigin,
context.config.rpId
);
const installCtx = { ...context, db } as Context;
const tempContextDb = { ...context, db } as Context;
await setSetting(installCtx, "issuer", context.config.issuer);
await setSetting(installCtx, "public_origin", context.config.publicOrigin);
await setSetting(installCtx, "rp_id", context.config.rpId);
await setSetting(
installCtx,
"users.self_registration_enabled",
Expand Down Expand Up @@ -174,13 +167,24 @@ async function _postInstallComplete(
const demoConfidentialSecretEnc = await kekService.encrypt(
Buffer.from(demoConfidentialClientSecret)
);
await (await import("../../models/install.ts")).seedDefaultClients(
installCtx,
demoConfidentialSecretEnc,
context.config.publicOrigin
);
await installCtx.db
.update(clients)
.set({
clientSecretEnc: demoConfidentialSecretEnc,
updatedAt: new Date(),
})
.where(eq(clients.clientId, "demo-confidential-client"));
const normalizedOrigin = context.config.publicOrigin.replace(/\/+$/, "");
await installCtx.db
.update(clients)
.set({
redirectUris: [`${normalizedOrigin}/callback`],
postLogoutRedirectUris: [normalizedOrigin],
allowedZkOrigins: [normalizedOrigin],
updatedAt: new Date(),
})
.where(eq(clients.clientId, "user"));
await (await import("../../models/install.ts")).ensureDefaultOrganizationAndSchema(installCtx);
await (await import("../../models/install.ts")).seedDefaultOrganizationRbac(installCtx);

context.logger.debug(
"[install:post] verifying admin user was created during OPAQUE registration"
Expand Down
34 changes: 10 additions & 24 deletions packages/api/src/models/install.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,19 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseClientScopeDefinitions } from "../utils/clientScopes.ts";
import { buildDefaultClientSeeds } from "./install.ts";
import {
parseClientScopeDefinitions,
serializeClientScopeDefinitions,
} from "../utils/clientScopes.ts";

test("buildDefaultClientSeeds includes structured scope definitions", () => {
const demoSecret = Buffer.from("demo-secret");
const seeds = buildDefaultClientSeeds(demoSecret, "https://auth.example.com");

assert.equal(seeds.length, 3);
assert.equal(seeds[0]?.clientId, "user");
assert.equal(seeds[1]?.clientId, "demo-public-client");
assert.equal(seeds[2]?.clientId, "demo-confidential-client");
assert.equal(seeds[2]?.clientSecretEnc, demoSecret);

const userScopes = parseClientScopeDefinitions(seeds[0]?.scopes ?? []);
const publicScopes = parseClientScopeDefinitions(seeds[1]?.scopes ?? []);
const confidentialScopes = parseClientScopeDefinitions(seeds[2]?.scopes ?? []);

assert.deepEqual(userScopes, [
{ key: "openid", description: "Authenticate you" },
{ key: "profile", description: "Access your profile information" },
{ key: "email", description: "Access your email address" },
]);
assert.deepEqual(publicScopes, [
test("client scope serialization preserves structured entries", () => {
const scopes = serializeClientScopeDefinitions([
{ key: "openid", description: "Authenticate you" },
{ key: "profile", description: "Access your profile information" },
{ key: "email", description: "Access your email address" },
{ key: "darkauth.users:read", description: "Search and read users from the directory" },
]);
assert.deepEqual(confidentialScopes, [
const parsedScopes = parseClientScopeDefinitions(scopes);

assert.deepEqual(parsedScopes, [
{ key: "openid", description: "Authenticate you" },
{ key: "profile", description: "Access your profile information" },
{ key: "darkauth.users:read", description: "Search and read users from the directory" },
Expand Down
Loading
Loading