fix(rbac): port Better-Auth user auto-provision into legacy ./src tree (GRO-2052) (#143)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 36s
CI / Build & Push Docker Images (pull_request) Successful in 26s
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 36s
CI / Build & Push Docker Images (pull_request) Successful in 26s
fix(rbac): port Better-Auth user auto-provision into legacy ./src tree (GRO-2052) Ports the Better-Auth user-table auto-provision branch from canonical apps/api into the deployed ./src/middleware/rbac.ts so the owner-bypass in pets.ts is reachable for Better-Auth email/password customers. OIDC account branch retained as backward-compat fallback. Adds 5 rbac.test.ts cases and UAT_PLAYBOOK pre-condition docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Flea Flicker <flea@groombook.dev> Co-committed-by: Flea Flicker <flea@groombook.dev>
This commit was merged in pull request #143.
This commit is contained in:
@@ -40,6 +40,24 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
|
|||||||
|
|
||||||
**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request.
|
**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request.
|
||||||
|
|
||||||
|
### rbac auto-provision for Better-Auth customers (GRO-2052)
|
||||||
|
|
||||||
|
> Applies to TC-API-3.16 / 3.19a / 3.19b / 3.19c (customer-as-owner profile-summary paths) and any future case where the test user authenticates via Better-Auth email/password and the route relies on `resolveStaffMiddleware` to resolve a `staff` row.
|
||||||
|
|
||||||
|
**Pre-condition (rbac auto-provision):** The test user must have a row in the Better-Auth `user` table (email/password sign-in creates this automatically — see TC-API-1.6 / 1.7). On first authenticated call, `resolveStaffMiddleware` (`./src/middleware/rbac.ts`) auto-provisions a `groomer` staff row keyed by `staff.user_id = user.id` (Better-Auth branch fires before the legacy OIDC `account` branch).
|
||||||
|
|
||||||
|
**Verify the auto-provision fired** by querying the DB after the first authenticated call:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT user_id, role FROM staff WHERE user_id = '<test-user-id>';
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the OIDC `account` branch and 403'd, or the user has no `user` row — fix the test sign-in path before re-running.
|
||||||
|
|
||||||
|
**Why this matters:** without the auto-provision branch, Better-Auth email/password customers (e.g. `uat-customer@groombook.dev`) have no `account` row for the OIDC providers, so `resolveStaffMiddleware` falls through to `403 "Forbidden: no staff record found for authenticated user"` *before* `pets.ts` can run the owner-bypass added in GRO-2013. The owner-bypass code is unreachable unless the auto-provision has fired. A green TC-API-3.19a therefore implicitly proves the auto-provision worked; if 3.19a fails with the pre-fix 403, the auto-provision branch is missing from the deployed `./src` tree (see [GRO-2052](/GRO/issues/GRO-2052)).
|
||||||
|
|
||||||
|
**How to apply:** for every run of TC-API-3.16 / 3.19a / 3.19b / 3.19c, sign in via TC-API-1.6 (email+password) first to guarantee the `user` row exists, then run the profile-summary call, then assert the `staff` row above before declaring pass.
|
||||||
|
|
||||||
## Test Cases
|
## Test Cases
|
||||||
|
|
||||||
### 4.0 Health Check
|
### 4.0 Health Check
|
||||||
|
|||||||
+214
-25
@@ -43,42 +43,103 @@ const GROOMER: StaffRow = {
|
|||||||
|
|
||||||
// ─── Mock DB ──────────────────────────────────────────────────────────────────
|
// ─── Mock DB ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// staffLookupResult drives every `from(staff)` query that doesn't go through
|
||||||
|
// the dev-mode `.limit()` shortcut. Tests that want to simulate "no staff row"
|
||||||
|
// leave it null.
|
||||||
let staffLookupResult: StaffRow | null = null;
|
let staffLookupResult: StaffRow | null = null;
|
||||||
|
|
||||||
|
// managerFallbackResult is only consumed by the dev-mode `from(staff).limit(1)`
|
||||||
|
// path (looking up the first manager when AUTH_DISABLED=true and no header).
|
||||||
let managerFallbackResult: StaffRow | null = MANAGER;
|
let managerFallbackResult: StaffRow | null = MANAGER;
|
||||||
|
|
||||||
|
// userLookupResult drives `from(user).limit(1)` for the Better-Auth user
|
||||||
|
// auto-provision branch (GRO-2052). Tests that simulate "no Better-Auth user"
|
||||||
|
// leave it null.
|
||||||
|
type UserRow = { id: string; name: string | null; email: string | null };
|
||||||
|
let userLookupResult: UserRow | null = null;
|
||||||
|
|
||||||
|
// accountLookupResult drives `from(account).limit(1)` for the legacy OIDC
|
||||||
|
// auto-provision branch. Null means "no OIDC account row".
|
||||||
|
let accountLookupResult: { id: string } | null = null;
|
||||||
|
|
||||||
|
// insertReturningResult drives `insert(staff).values(...).returning()` for
|
||||||
|
// any auto-provision branch that actually creates a staff record. Null means
|
||||||
|
// the INSERT returned no rows (simulating a DB failure).
|
||||||
|
let insertReturningResult: StaffRow | null = null;
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
vi.mock("@groombook/db", () => {
|
||||||
const staff = new Proxy(
|
function tableMarker(name: string) {
|
||||||
{ _name: "staff" },
|
return new Proxy(
|
||||||
{
|
{ _name: name },
|
||||||
get(target, prop) {
|
{
|
||||||
if (prop === "_name") return "staff";
|
get(_target, prop) {
|
||||||
if (prop === "$inferSelect") return {};
|
if (prop === "_name") return name;
|
||||||
return { table: "staff", column: prop };
|
if (prop === "$inferSelect") return {};
|
||||||
},
|
return { table: name, column: prop };
|
||||||
}
|
},
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staff = tableMarker("staff");
|
||||||
|
const user = tableMarker("user");
|
||||||
|
const account = tableMarker("account");
|
||||||
|
|
||||||
|
function lookupFor(tableName: string) {
|
||||||
|
if (tableName === "user") return userLookupResult;
|
||||||
|
if (tableName === "account") return accountLookupResult;
|
||||||
|
return staffLookupResult;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: (_columns?: unknown) => ({
|
||||||
from: () => ({
|
from: (table: { _name?: string }) => {
|
||||||
where: () => ({
|
const name = table?._name ?? "staff";
|
||||||
limit: () => {
|
return {
|
||||||
// dev mode fallback to first manager
|
where: () => ({
|
||||||
return managerFallbackResult ? [managerFallbackResult] : [];
|
limit: () => {
|
||||||
},
|
// The user / account auto-provision branches always call
|
||||||
[Symbol.iterator]: function* () {
|
// `.limit(1)`; route to the per-table lookup state.
|
||||||
if (staffLookupResult) yield staffLookupResult;
|
if (name === "user")
|
||||||
},
|
return userLookupResult ? [userLookupResult] : [];
|
||||||
0: staffLookupResult,
|
if (name === "account")
|
||||||
length: staffLookupResult ? 1 : 0,
|
return accountLookupResult ? [accountLookupResult] : [];
|
||||||
}),
|
// dev-mode `from(staff).limit(1)` falls back to the first
|
||||||
|
// manager when AUTH_DISABLED is set with no header.
|
||||||
|
return managerFallbackResult ? [managerFallbackResult] : [];
|
||||||
|
},
|
||||||
|
[Symbol.iterator]: function* () {
|
||||||
|
const row = lookupFor(name);
|
||||||
|
if (row) yield row;
|
||||||
|
},
|
||||||
|
0: lookupFor(name),
|
||||||
|
length: lookupFor(name) ? 1 : 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
insert: (_table: unknown) => ({
|
||||||
|
values: (_v: unknown) => ({
|
||||||
|
returning: () =>
|
||||||
|
insertReturningResult ? [insertReturningResult] : [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: unknown) => ({
|
||||||
|
set: (_v: unknown) => ({
|
||||||
|
where: () => Promise.resolve(undefined),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
staff,
|
staff,
|
||||||
|
user,
|
||||||
|
account,
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||||
|
sql: Object.assign(
|
||||||
|
vi.fn((..._tpl: unknown[]) => ({})),
|
||||||
|
{ raw: vi.fn(() => ({})) }
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,16 +148,25 @@ vi.mock("@groombook/db", () => {
|
|||||||
function resetMocks() {
|
function resetMocks() {
|
||||||
staffLookupResult = null;
|
staffLookupResult = null;
|
||||||
managerFallbackResult = MANAGER;
|
managerFallbackResult = MANAGER;
|
||||||
|
userLookupResult = null;
|
||||||
|
accountLookupResult = null;
|
||||||
|
insertReturningResult = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
||||||
function buildApp(
|
function buildApp(
|
||||||
middleware: MiddlewareHandler<AppEnv>,
|
middleware: MiddlewareHandler<AppEnv>,
|
||||||
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
|
handler?: (c: Context<AppEnv>) => Response | Promise<Response>,
|
||||||
|
jwtOverride?: Partial<{ sub: string; email: string; name: string }>
|
||||||
) {
|
) {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
const defaultSub = staffLookupResult?.userId ?? "unknown-sub";
|
||||||
|
c.set("jwtPayload", {
|
||||||
|
sub: jwtOverride?.sub ?? defaultSub,
|
||||||
|
...(jwtOverride?.email !== undefined ? { email: jwtOverride.email } : {}),
|
||||||
|
...(jwtOverride?.name !== undefined ? { name: jwtOverride.name } : {}),
|
||||||
|
});
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
app.use("*", middleware);
|
app.use("*", middleware);
|
||||||
@@ -204,6 +274,125 @@ describe("resolveStaffMiddleware", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Auto-provision branches (GRO-2052) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each branch creates a staff row on first authenticated request when no row
|
||||||
|
// exists yet. The Better-Auth branch (user table) is the primary path for
|
||||||
|
// email/password customers; the OIDC branch (account table) is a fallback for
|
||||||
|
// legacy authentik/google/github sessions.
|
||||||
|
|
||||||
|
describe("resolveStaffMiddleware — auto-provision", () => {
|
||||||
|
const PROVISIONED: StaffRow = {
|
||||||
|
...MANAGER,
|
||||||
|
id: "staff-provisioned-id",
|
||||||
|
oidcSub: null,
|
||||||
|
userId: "ba-user-customer",
|
||||||
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
|
name: "UAT Customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("Better-Auth: creates a groomer staff row when user exists but no staff record (GRO-2052)", async () => {
|
||||||
|
// No existing staff row, no OIDC account row, but a Better-Auth user row.
|
||||||
|
staffLookupResult = null;
|
||||||
|
userLookupResult = {
|
||||||
|
id: "ba-user-customer",
|
||||||
|
name: "UAT Customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
};
|
||||||
|
accountLookupResult = null;
|
||||||
|
insertReturningResult = PROVISIONED;
|
||||||
|
|
||||||
|
let capturedStaff: StaffRow | null = null;
|
||||||
|
const app = buildApp(
|
||||||
|
resolveStaffMiddleware,
|
||||||
|
(c) => {
|
||||||
|
capturedStaff = c.get("staff");
|
||||||
|
return c.json({ ok: true });
|
||||||
|
},
|
||||||
|
{ sub: "ba-user-customer", email: "uat-customer@groombook.dev" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(capturedStaff).not.toBeNull();
|
||||||
|
expect(capturedStaff!.role).toBe("groomer");
|
||||||
|
expect(capturedStaff!.userId).toBe("ba-user-customer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Better-Auth: returns 500 if INSERT yields no row", async () => {
|
||||||
|
staffLookupResult = null;
|
||||||
|
userLookupResult = {
|
||||||
|
id: "ba-user-customer",
|
||||||
|
name: "UAT Customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
};
|
||||||
|
insertReturningResult = null; // simulate INSERT … RETURNING returning []
|
||||||
|
|
||||||
|
const app = buildApp(resolveStaffMiddleware, undefined, {
|
||||||
|
sub: "ba-user-customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/auto-provision failed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Better-Auth branch runs before OIDC branch (does not require jwt.email)", async () => {
|
||||||
|
// A Better-Auth user row alone is sufficient: jwt.email is intentionally
|
||||||
|
// absent. The pre-GRO-2052 code only auto-provisioned inside `if (jwt.email)`.
|
||||||
|
staffLookupResult = null;
|
||||||
|
userLookupResult = {
|
||||||
|
id: "ba-user-customer",
|
||||||
|
name: "UAT Customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
};
|
||||||
|
insertReturningResult = PROVISIONED;
|
||||||
|
|
||||||
|
const app = buildApp(resolveStaffMiddleware, undefined, {
|
||||||
|
sub: "ba-user-customer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("OIDC fallback: still provisions when user row is missing but account row exists", async () => {
|
||||||
|
// No staff row, no Better-Auth user, but an OIDC account row.
|
||||||
|
staffLookupResult = null;
|
||||||
|
userLookupResult = null;
|
||||||
|
accountLookupResult = { id: "oidc-account-id" };
|
||||||
|
insertReturningResult = { ...PROVISIONED, userId: "oidc-sub" };
|
||||||
|
|
||||||
|
const app = buildApp(resolveStaffMiddleware, undefined, {
|
||||||
|
sub: "oidc-sub",
|
||||||
|
email: "oidc-user@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to 403 when neither Better-Auth user nor OIDC account row exists", async () => {
|
||||||
|
staffLookupResult = null;
|
||||||
|
userLookupResult = null;
|
||||||
|
accountLookupResult = null;
|
||||||
|
|
||||||
|
const app = buildApp(resolveStaffMiddleware, undefined, {
|
||||||
|
sub: "ghost-sub",
|
||||||
|
email: "ghost@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/no staff record/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── requireRole tests ────────────────────────────────────────────────────────
|
// ─── requireRole tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("requireRole", () => {
|
describe("requireRole", () => {
|
||||||
|
|||||||
+43
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
|
import { and, eq, getDb, sql, staff, account, user } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -111,8 +111,49 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-provision for Better-Auth users (GRO-2052): the user signed in via
|
||||||
|
// Better-Auth (email/password, magic link, etc.), so a row exists in `user`
|
||||||
|
// for jwt.sub but no `account` provider row is required. Create a minimal
|
||||||
|
// groomer staff record on first login. This is the primary auto-provision
|
||||||
|
// path; the OIDC branch below remains as a fallback for legacy accounts
|
||||||
|
// that exist in `account` but not in `user`.
|
||||||
|
const [userRow] = await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, jwt.sub))
|
||||||
|
.limit(1);
|
||||||
|
if (userRow) {
|
||||||
|
const emailPrefix = userRow.email ? userRow.email.split("@")[0] : "Unknown";
|
||||||
|
const name = userRow.name?.trim() || jwt.name?.trim() || emailPrefix;
|
||||||
|
|
||||||
|
const [newStaff] = await db
|
||||||
|
.insert(staff)
|
||||||
|
.values({
|
||||||
|
userId: jwt.sub,
|
||||||
|
email: userRow.email ?? jwt.email ?? "",
|
||||||
|
name,
|
||||||
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
|
active: true,
|
||||||
|
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
|
||||||
|
.returning()!;
|
||||||
|
|
||||||
|
if (!newStaff) {
|
||||||
|
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[rbac] auto-provisioned staff record for Better-Auth user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
|
||||||
|
);
|
||||||
|
c.set("staff", newStaff);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
|
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
|
||||||
// (e.g. authentik). If so, create a groomer staff record on the fly.
|
// (e.g. authentik). If so, create a groomer staff record on the fly. This
|
||||||
|
// is kept for backward compatibility with legacy OIDC sessions whose user
|
||||||
|
// row may not yet exist in the Better-Auth `user` table.
|
||||||
if (jwt.email) {
|
if (jwt.email) {
|
||||||
const [oidcAccount] = await db
|
const [oidcAccount] = await db
|
||||||
.select({ id: account.id })
|
.select({ id: account.id })
|
||||||
|
|||||||
Reference in New Issue
Block a user