feat(oobe): conditional auth provider bootstrap step + fix(rbac): requireRoleOrSuperUser for /admin/* (GRO-392, GRO-412)

Merges GRO-392 (OOBE auth provider bootstrap step) and GRO-412 (fix admin route RBAC to use requireRoleOrSuperUser). QA  CTO . Approved by CEO.
This commit was merged in pull request #214.
This commit is contained in:
groombook-engineer[bot]
2026-04-03 01:55:13 +00:00
committed by GitHub
parent 7e584effaa
commit 2a50850217
5 changed files with 714 additions and 38 deletions
+64 -1
View File
@@ -124,7 +124,7 @@ function buildWithStaff(
// ─── Import middleware ────────────────────────────────────────────────────────
const { resolveStaffMiddleware, requireRole, requireSuperUser } = await import(
const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import(
"../middleware/rbac.js"
);
@@ -326,3 +326,66 @@ describe("requireSuperUser", () => {
expect(contentType).toContain("application/json");
});
});
// ─── requireRoleOrSuperUser tests ─────────────────────────────────────────────
describe("requireRoleOrSuperUser", () => {
it("allows a manager to access manager-only routes", async () => {
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => {
// GRO-412: a receptionist granted super user via Staff UI should access admin routes
const superReceptionist: StaffRow = {
...RECEPTIONIST,
isSuperUser: true,
};
const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows a super user with groomer role to access manager-only routes", async () => {
const superGroomer: StaffRow = {
...GROOMER,
isSuperUser: true,
};
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("blocks a non-super-user receptionist from manager-only routes", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i);
});
it("blocks a non-super-user groomer from manager-only routes", async () => {
const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i);
});
it("allows a manager with multiple allowed roles", async () => {
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows a super user with disallowed role to access route with multiple allowed roles", async () => {
const superGroomer: StaffRow = {
...GROOMER,
isSuperUser: true,
};
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
});
+1 -1
View File
@@ -109,7 +109,7 @@ api.route("/auth", authRouter);
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
api.use("/admin/*", requireRole("manager"));
api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
+190
View File
@@ -0,0 +1,190 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db";
import { requireSuperUser } from "../../middleware/rbac.js";
export const authProviderRouter = new Hono();
// ─── Schemas ─────────────────────────────────────────────────────────────────
const putAuthProviderSchema = z.object({
providerId: z.string().min(1).max(100),
displayName: z.string().min(1).max(200),
issuerUrl: z.string().url(),
internalBaseUrl: z.string().url().nullable().optional(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
scopes: z.string().default("openid profile email"),
});
// ─── GET /api/admin/auth-provider ────────────────────────────────────────────
authProviderRouter.get("/", requireSuperUser(), async (c) => {
const db = getDb();
const [row] = await db
.select()
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
if (!row) {
return c.json({ exists: false, config: null });
}
// Return config with secret redacted
return c.json({
exists: true,
config: {
id: row.id,
providerId: row.providerId,
displayName: row.displayName,
issuerUrl: row.issuerUrl,
internalBaseUrl: row.internalBaseUrl,
clientId: row.clientId,
clientSecret: "••••••••",
scopes: row.scopes,
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
},
});
});
// ─── PUT /api/admin/auth-provider ───────────────────────────────────────────
authProviderRouter.put(
"/",
requireSuperUser(),
zValidator("json", putAuthProviderSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
// Encrypt the client secret before storing
const encryptedSecret = encryptSecret(body.clientSecret);
// Check if config already exists
const [existing] = await db
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.providerId, body.providerId))
.limit(1);
let saved;
if (existing) {
// Update existing
[saved] = await db
.update(authProviderConfig)
.set({
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
updatedAt: new Date(),
})
.where(eq(authProviderConfig.id, existing.id))
.returning();
} else {
// Insert new
[saved] = await db
.insert(authProviderConfig)
.values({
providerId: body.providerId,
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
enabled: true,
})
.returning();
}
// Return config with secret redacted
return c.json({
id: saved!.id,
providerId: saved!.providerId,
displayName: saved!.displayName,
issuerUrl: saved!.issuerUrl,
internalBaseUrl: saved!.internalBaseUrl,
clientId: saved!.clientId,
clientSecret: "••••••••",
scopes: saved!.scopes,
enabled: saved!.enabled,
createdAt: saved!.createdAt,
updatedAt: saved!.updatedAt,
});
}
);
// ─── POST /api/admin/auth-provider/test ─────────────────────────────────────
const testAuthProviderSchema = z.object({
providerId: z.string().min(1).max(100),
issuerUrl: z.string().url(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
});
authProviderRouter.post(
"/test",
requireSuperUser(),
zValidator("json", testAuthProviderSchema),
async (c) => {
const { issuerUrl } = c.req.valid("json");
// Fetch OIDC discovery document
const discoveryUrl = `${issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`;
let metadata: Record<string, unknown> | null = null;
let errorMessage: string | null = null;
try {
const response = await fetch(discoveryUrl, {
method: "GET",
headers: { "Accept": "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
} else {
metadata = (await response.json()) as Record<string, unknown>;
}
} catch (err) {
errorMessage =
err instanceof Error ? err.message : "Failed to fetch OIDC discovery document";
}
if (errorMessage) {
return c.json({ ok: false, error: errorMessage });
}
return c.json({ ok: true, metadata });
}
);
// ─── DELETE /api/admin/auth-provider ────────────────────────────────────────
authProviderRouter.delete("/", requireSuperUser(), async (c) => {
const db = getDb();
// Get the current config
const [existing] = await db
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
if (!existing) {
return c.json({ ok: true, message: "No DB config to delete" });
}
await db.delete(authProviderConfig).where(eq(authProviderConfig.id, existing.id));
return c.json({ ok: true, message: "Auth provider config removed; auth will fall back to env vars" });
});
+149 -3
View File
@@ -1,12 +1,13 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, staff, businessSettings } from "@groombook/db";
import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => {
const db = getDb();
@@ -17,7 +18,26 @@ setupRouter.get("/status", async (c) => {
.where(eq(staff.isSuperUser, true))
.limit(1);
return c.json({ needsSetup: !superUser });
// Check if DB already has an auth provider config
const [dbAuthConfig] = await db
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
// Check if OIDC env vars are set (bootstrap mode)
const oidcIssuer = process.env.OIDC_ISSUER;
const oidcClientId = process.env.OIDC_CLIENT_ID;
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
const authEnvVarsSet = !!(oidcIssuer && oidcClientId && oidcClientSecret);
return c.json({
needsSetup: !superUser,
// Show auth provider bootstrap step when: fresh install (no super user) AND no DB config AND no env vars
showAuthProviderStep: !superUser && !dbAuthConfig && !authEnvVarsSet,
authConfigExists: !!dbAuthConfig,
authEnvVarsSet,
});
});
const setupSchema = z.object({
@@ -76,4 +96,130 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
}
return c.json({ ok: true, staff: result.staff }, 201);
});
});
// ─── Auth Provider Bootstrap ──────────────────────────────────────────────────
const authProviderBootstrapSchema = z.object({
providerId: z.string().min(1).max(100),
displayName: z.string().min(1).max(200),
issuerUrl: z.string().url(),
internalBaseUrl: z.string().url().nullable().optional(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
scopes: z.string().default("openid profile email"),
});
/**
* POST /api/setup/auth-provider
* Unauthenticated endpoint for first-time auth provider setup during OOBE.
* Only available when needsSetup is true (no super user = fresh install).
* Rate-limited by the API gateway; additionally restricted to first-time setup only.
* After setup completes, this endpoint permanently returns 403.
*/
setupRouter.post("/auth-provider", zValidator("json", authProviderBootstrapSchema), async (c) => {
const db = getDb();
// Guard: only allow during fresh install (no super user yet)
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
if (superUser) {
// Setup already completed — lock this endpoint permanently
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
}
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
const [existingConfig] = await db
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
if (existingConfig) {
return c.json({ error: "Auth provider is already configured." }, 409);
}
const body = c.req.valid("json");
// Encrypt clientSecret before storing
const encryptedSecret = encryptSecret(body.clientSecret);
const [row] = await db
.insert(authProviderConfig)
.values({
providerId: body.providerId,
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
enabled: true,
})
.returning();
if (!row) {
return c.json({ error: "Failed to save auth provider configuration." }, 500);
}
return c.json({
id: row.id,
providerId: row.providerId,
displayName: row.displayName,
issuerUrl: row.issuerUrl,
internalBaseUrl: row.internalBaseUrl,
clientId: row.clientId,
scopes: row.scopes,
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}, 201);
});
/**
* POST /api/setup/auth-provider/test
* Unauthenticated endpoint to validate an OIDC provider configuration during OOBE.
* Fetches the OIDC discovery document to confirm the issuer is reachable.
* Only available when needsSetup is true (no super user = fresh install).
*/
setupRouter.post("/auth-provider/test", zValidator("json", authProviderBootstrapSchema), async (c) => {
const db = getDb();
// Guard: only allow during fresh install (no super user yet)
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
if (superUser) {
return c.json({ ok: false, error: "Setup has already been completed." }, 403);
}
const body = c.req.valid("json");
// Determine the discovery URL
const discoveryUrl = body.internalBaseUrl
? `${body.internalBaseUrl}/application/o/.well-known/openid-configuration`
: `${body.issuerUrl}/.well-known/openid-configuration`;
try {
const res = await fetch(discoveryUrl, { method: "GET" });
if (!res.ok) {
return c.json({
ok: false,
error: `OIDC discovery failed (HTTP ${res.status}). Check your Issuer URL and Internal Base URL.`,
});
}
return c.json({ ok: true });
} catch {
return c.json({
ok: false,
error: "Could not reach the OIDC provider. Check your Issuer URL and network connectivity.",
});
}
});