@tatara/cli 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +367 -93
- package/package.json +1 -1
- package/templates/features/admin/files/src/apps/admin/app.ts +1 -1
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +2 -2
- package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +11 -4
- package/templates/features/admin/files/src/modules/staff/db/seed.ts +27 -0
- package/templates/features/admin/files/src/modules/staff/services/auth.ts +46 -0
- package/templates/features/admin/files/tests/helpers/staff.ts +8 -0
- package/templates/features/admin/files+drizzle+mysql/src/modules/staff/db/staff.schema.ts +58 -0
- package/templates/features/admin/files+drizzle+mysql/src/modules/staff/services/adapter.ts +13 -0
- package/templates/features/admin/files+drizzle+postgres/src/modules/staff/db/staff.schema.ts +57 -0
- package/templates/features/admin/files+drizzle+postgres/src/modules/staff/services/adapter.ts +13 -0
- package/templates/features/admin/files+prisma/prisma/models/staff.prisma +61 -0
- package/templates/features/admin/files+prisma+mysql/src/modules/staff/services/adapter.ts +5 -0
- package/templates/features/admin/files+prisma+postgres/src/modules/staff/services/adapter.ts +5 -0
- package/templates/features/auth-keycloak/files/keycloak/realm.json +70 -9
- package/templates/features/auth-keycloak/files/src/{core/auth → modules/users}/commands/keycloak-token.ts +3 -1
- package/templates/features/auth-keycloak/files/{src/core/auth/tests/keycloak-tokens.test.ts → tests/tatara/auth-keycloak-tokens.test.ts} +2 -2
- package/templates/features/auth-keycloak/files+auth-orgs/{src/core/auth/tests/keycloak-organizations.test.ts → tests/tatara/auth-keycloak-organizations.test.ts} +1 -1
- package/templates/features/auth-keycloak/files+authz/{src/core/auth/tests/keycloak-authz.test.ts → tests/tatara/auth-keycloak-authz.test.ts} +3 -2
- package/templates/features/auth-orgs/files+drizzle+mysql/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
- package/templates/features/auth-orgs/files+drizzle+postgres/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
- package/templates/features/authz/files/src/core/authz/system-roles.ts +1 -0
- package/templates/features/authz/files/src/{core/authz → modules/users}/routes/authorization.routes.ts +4 -1
- package/templates/features/authz/files/{src/core/authz/tests → tests/tatara}/authz.test.ts +4 -2
- package/templates/features/authz/files+drizzle/src/core/authz/index.ts +1 -0
- package/templates/features/authz/files+prisma/src/core/authz/index.ts +1 -0
- package/templates/features/base/files/src/apps/api/app.ts +1 -1
- package/templates/features/base/files/src/core/http/context.ts +2 -0
- package/templates/features/better-auth/files/src/{core/auth → modules/users}/db/seed.ts +1 -1
- package/templates/features/better-auth/files/src/modules/users/services/auth.ts +37 -0
- package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/delivery.ts +1 -1
- package/templates/features/better-auth/files/tests/helpers/auth.ts +4 -3
- package/templates/features/better-auth/files+admin/tests/tatara/auth-openapi-admin.test.ts +10 -0
- package/templates/features/better-auth/files+auth-orgs/{src/core/auth/tests/principal.test.ts → tests/tatara/auth-principal.test.ts} +1 -1
- package/templates/features/better-auth/files+auth-orgs+authz/{src/core/auth/tests/principal-authz.test.ts → tests/tatara/auth-principal-authz.test.ts} +1 -1
- package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users/services}/adapter.ts +1 -1
- package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users/services}/adapter.ts +1 -1
- package/templates/versions.json +4 -4
- package/templates/features/better-auth/files/src/core/auth/auth.ts +0 -26
- package/templates/features/better-auth/files/src/core/auth/principal.ts +0 -10
- package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +0 -8
- /package/templates/features/auth/files/src/{core/auth → modules/users}/routes/me.routes.ts +0 -0
- /package/templates/features/auth/files/{src/core/auth/tests/me.test.ts → tests/tatara/auth-me.test.ts} +0 -0
- /package/templates/features/auth-keycloak/files/src/{core/auth → modules/users/services}/keycloak.ts +0 -0
- /package/templates/features/authz/files/{src/core/authz/tests/error-messages.test.ts → tests/tatara/authz-error-messages.test.ts} +0 -0
- /package/templates/features/authz/files/{src/core/authz/tests/request-body.test.ts → tests/tatara/authz-request-body.test.ts} +0 -0
- /package/templates/features/base/files/src/{core/http/app.ts → apps/http.ts} +0 -0
- /package/templates/features/base/files/{src/core/config/tests/env.test.ts → tests/tatara/config-env.test.ts} +0 -0
- /package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/sms.ts +0 -0
- /package/templates/features/better-auth/files/{src/core/auth/tests/openapi.test.ts → tests/tatara/auth-openapi.test.ts} +0 -0
- /package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
- /package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
- /package/templates/features/better-auth/files+prisma+mysql/src/{core/auth → modules/users/services}/adapter.ts +0 -0
- /package/templates/features/better-auth/files+prisma+postgres/src/{core/auth → modules/users/services}/adapter.ts +0 -0
- /package/templates/features/email/files/{src/core/email/tests → tests/tatara}/email.test.ts +0 -0
- /package/templates/features/i18n/files/{src/core/i18n/tests → tests/tatara}/i18n.test.ts +0 -0
- /package/templates/features/storage/files/{src/core/storage/tests → tests/tatara}/storage.test.ts +0 -0
package/package.json
CHANGED
|
@@ -11,8 +11,8 @@ router.openapi(
|
|
|
11
11
|
method: "get",
|
|
12
12
|
path: "/",
|
|
13
13
|
tags: ["Example"],
|
|
14
|
-
summary: "Greet the signed-in
|
|
15
|
-
security: [{ bearerAuth: ["
|
|
14
|
+
summary: "Greet the signed-in staff member",
|
|
15
|
+
security: [{ bearerAuth: ["staff"] }],
|
|
16
16
|
responses: {
|
|
17
17
|
200: jsonContent(successSchema(greetingSchema), "A greeting"),
|
|
18
18
|
...errorResponses(401, 403),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { adminApi } from "#tests/helpers/api";
|
|
3
3
|
import { asUser } from "#tests/helpers/auth";
|
|
4
|
+
import { asStaff } from "#tests/helpers/staff";
|
|
4
5
|
|
|
5
6
|
describe("admin app", () => {
|
|
6
7
|
it("requires authentication", async () => {
|
|
@@ -8,13 +9,19 @@ describe("admin app", () => {
|
|
|
8
9
|
expect(res.status).toBe(401);
|
|
9
10
|
});
|
|
10
11
|
|
|
11
|
-
it("
|
|
12
|
-
const res = await adminApi.get("/api/greetings", { headers: await asUser() });
|
|
12
|
+
it("refuses users: the admin app is for staff", async () => {
|
|
13
|
+
const res = await adminApi.get("/api/greetings", { headers: await asUser({ roles: ["admin"] }) });
|
|
14
|
+
// a users session isn't known here (401); a Keycloak token without the staff role is (403)
|
|
15
|
+
expect([401, 403]).toContain(res.status);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("forbids staff without the staff role", async () => {
|
|
19
|
+
const res = await adminApi.get("/api/greetings", { headers: await asStaff({ role: "intern" }) });
|
|
13
20
|
expect(res.status).toBe(403);
|
|
14
21
|
});
|
|
15
22
|
|
|
16
|
-
it("serves
|
|
17
|
-
const res = await adminApi.get("/api/greetings", { headers: await
|
|
23
|
+
it("serves staff", async () => {
|
|
24
|
+
const res = await adminApi.get("/api/greetings", { headers: await asStaff() });
|
|
18
25
|
expect(res.status).toBe(200);
|
|
19
26
|
});
|
|
20
27
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineSeed } from "@tatara/db";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { staffAuth } from "#modules/staff/services/auth";
|
|
4
|
+
|
|
5
|
+
/** Development staff account for the admin app (never created in production). */
|
|
6
|
+
export default defineSeed({
|
|
7
|
+
name: "staff:dev-admin",
|
|
8
|
+
order: 0,
|
|
9
|
+
async run({ logger }) {
|
|
10
|
+
if (env.NODE_ENV !== "development" && env.NODE_ENV !== "test") return;
|
|
11
|
+
if (!env.SEED_STAFF_PASSWORD) return;
|
|
12
|
+
const ctx = await staffAuth.$context;
|
|
13
|
+
const email = env.SEED_STAFF_EMAIL;
|
|
14
|
+
if (await ctx.internalAdapter.findUserByEmail(email)) return;
|
|
15
|
+
const user = await ctx.internalAdapter.createUser(
|
|
16
|
+
{ email, name: "Dev staff", emailVerified: true, role: "staff" },
|
|
17
|
+
{ method: "admin" },
|
|
18
|
+
);
|
|
19
|
+
await ctx.internalAdapter.linkAccount({
|
|
20
|
+
userId: user.id,
|
|
21
|
+
providerId: "credential",
|
|
22
|
+
accountId: user.id,
|
|
23
|
+
password: await ctx.password.hash(env.SEED_STAFF_PASSWORD),
|
|
24
|
+
});
|
|
25
|
+
logger.info({ email, userId: user.id }, "created development staff account");
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createAuthInstance } from "@tatara/auth";
|
|
2
|
+
import { newId } from "@tatara/db";
|
|
3
|
+
import { bearer, openAPI } from "better-auth/plugins";
|
|
4
|
+
import { env } from "#core/config/env";
|
|
5
|
+
import { staffDatabase } from "#modules/staff/services/adapter";
|
|
6
|
+
// tatara:slot imports
|
|
7
|
+
|
|
8
|
+
/** Every staff principal acts in this org, so platform permissions never mix with a customer org's. */
|
|
9
|
+
export const PLATFORM_ORG_ID = "platform";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A separate better-auth for internal staff, mounted on the admin app only. Staff and users never
|
|
13
|
+
* share a table, a session or a cookie. There's no sign-up: staff accounts come from the seed or
|
|
14
|
+
* a command.
|
|
15
|
+
*/
|
|
16
|
+
export const { auth: staffAuth, attachPrincipal: attachStaffPrincipal } = createAuthInstance(
|
|
17
|
+
{
|
|
18
|
+
appName: "__APP_NAME__ staff",
|
|
19
|
+
baseURL: env.ADMIN_URL,
|
|
20
|
+
basePath: "/api/auth",
|
|
21
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
22
|
+
trustedOrigins: env.CORS_ORIGINS,
|
|
23
|
+
database: staffDatabase,
|
|
24
|
+
user: {
|
|
25
|
+
modelName: "staffUser",
|
|
26
|
+
additionalFields: { role: { type: "string", defaultValue: "staff", input: false } },
|
|
27
|
+
},
|
|
28
|
+
session: { modelName: "staffSession" },
|
|
29
|
+
account: { modelName: "staffAccount" },
|
|
30
|
+
verification: { modelName: "staffVerification" },
|
|
31
|
+
advanced: { database: { generateId: () => newId() }, cookiePrefix: "staff" },
|
|
32
|
+
emailAndPassword: { enabled: true, minPasswordLength: 10, disableSignUp: true },
|
|
33
|
+
plugins: [
|
|
34
|
+
// Authorization: Bearer <session token> for API clients
|
|
35
|
+
bearer(),
|
|
36
|
+
// reference for the staff auth endpoints at /api/auth/reference on the admin app
|
|
37
|
+
openAPI(),
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
org: PLATFORM_ORG_ID,
|
|
42
|
+
resolvers: [
|
|
43
|
+
// tatara:slot resolvers
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createTestUser, sessionHeaders } from "@tatara/auth/testing";
|
|
2
|
+
import { staffAuth } from "#modules/staff/services/auth";
|
|
3
|
+
|
|
4
|
+
/** Headers that authenticate as a new staff member on the admin app. */
|
|
5
|
+
export async function asStaff(options: { role?: string } = {}): Promise<Record<string, string>> {
|
|
6
|
+
const userId = await createTestUser(staffAuth, { fields: { role: options.role ?? "staff" } });
|
|
7
|
+
return sessionHeaders(staffAuth, userId);
|
|
8
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Internal staff accounts: better-auth's tables under staff_* names, apart from users.
|
|
2
|
+
import { boolean, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
|
|
3
|
+
|
|
4
|
+
const id = () => varchar({ length: 36 });
|
|
5
|
+
const createdAt = () => timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow();
|
|
6
|
+
const updatedAt = () => timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow();
|
|
7
|
+
|
|
8
|
+
export const staffUsers = mysqlTable("staff_users", {
|
|
9
|
+
id: id().primaryKey(),
|
|
10
|
+
name: varchar({ length: 255 }).notNull(),
|
|
11
|
+
email: varchar({ length: 255 }).notNull().unique(),
|
|
12
|
+
emailVerified: boolean().notNull().default(false),
|
|
13
|
+
image: text(),
|
|
14
|
+
/** staff role, e.g. "staff"; the admin app requires one */
|
|
15
|
+
role: varchar({ length: 64 }).notNull().default("staff"),
|
|
16
|
+
createdAt: createdAt(),
|
|
17
|
+
updatedAt: updatedAt(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const staffSessions = mysqlTable("staff_sessions", {
|
|
21
|
+
id: id().primaryKey(),
|
|
22
|
+
expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
|
|
23
|
+
token: varchar({ length: 255 }).notNull().unique(),
|
|
24
|
+
ipAddress: varchar({ length: 64 }),
|
|
25
|
+
userAgent: text(),
|
|
26
|
+
userId: id()
|
|
27
|
+
.notNull()
|
|
28
|
+
.references(() => staffUsers.id, { onDelete: "cascade" }),
|
|
29
|
+
createdAt: createdAt(),
|
|
30
|
+
updatedAt: updatedAt(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const staffAccounts = mysqlTable("staff_accounts", {
|
|
34
|
+
id: id().primaryKey(),
|
|
35
|
+
accountId: varchar({ length: 255 }).notNull(),
|
|
36
|
+
providerId: varchar({ length: 64 }).notNull(),
|
|
37
|
+
userId: id()
|
|
38
|
+
.notNull()
|
|
39
|
+
.references(() => staffUsers.id, { onDelete: "cascade" }),
|
|
40
|
+
accessToken: text(),
|
|
41
|
+
refreshToken: text(),
|
|
42
|
+
idToken: text(),
|
|
43
|
+
accessTokenExpiresAt: timestamp({ fsp: 3, mode: "date" }),
|
|
44
|
+
refreshTokenExpiresAt: timestamp({ fsp: 3, mode: "date" }),
|
|
45
|
+
scope: text(),
|
|
46
|
+
password: text(),
|
|
47
|
+
createdAt: createdAt(),
|
|
48
|
+
updatedAt: updatedAt(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const staffVerifications = mysqlTable("staff_verifications", {
|
|
52
|
+
id: id().primaryKey(),
|
|
53
|
+
identifier: varchar({ length: 255 }).notNull(),
|
|
54
|
+
value: text().notNull(),
|
|
55
|
+
expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
|
|
56
|
+
createdAt: createdAt(),
|
|
57
|
+
updatedAt: updatedAt(),
|
|
58
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
2
|
+
import { db } from "#core/db/client";
|
|
3
|
+
import * as staff from "#modules/staff/db/staff.schema";
|
|
4
|
+
|
|
5
|
+
export const staffDatabase = drizzleAdapter(db, {
|
|
6
|
+
provider: "mysql",
|
|
7
|
+
schema: {
|
|
8
|
+
staffUser: staff.staffUsers,
|
|
9
|
+
staffSession: staff.staffSessions,
|
|
10
|
+
staffAccount: staff.staffAccounts,
|
|
11
|
+
staffVerification: staff.staffVerifications,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Internal staff accounts: better-auth's tables under staff_* names, apart from users.
|
|
2
|
+
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
const createdAt = () => timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow();
|
|
5
|
+
const updatedAt = () => timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow();
|
|
6
|
+
|
|
7
|
+
export const staffUsers = pgTable("staff_users", {
|
|
8
|
+
id: text().primaryKey(),
|
|
9
|
+
name: text().notNull(),
|
|
10
|
+
email: text().notNull().unique(),
|
|
11
|
+
emailVerified: boolean().notNull().default(false),
|
|
12
|
+
image: text(),
|
|
13
|
+
/** staff role, e.g. "staff"; the admin app requires one */
|
|
14
|
+
role: text().notNull().default("staff"),
|
|
15
|
+
createdAt: createdAt(),
|
|
16
|
+
updatedAt: updatedAt(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const staffSessions = pgTable("staff_sessions", {
|
|
20
|
+
id: text().primaryKey(),
|
|
21
|
+
expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
|
|
22
|
+
token: text().notNull().unique(),
|
|
23
|
+
ipAddress: text(),
|
|
24
|
+
userAgent: text(),
|
|
25
|
+
userId: text()
|
|
26
|
+
.notNull()
|
|
27
|
+
.references(() => staffUsers.id, { onDelete: "cascade" }),
|
|
28
|
+
createdAt: createdAt(),
|
|
29
|
+
updatedAt: updatedAt(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const staffAccounts = pgTable("staff_accounts", {
|
|
33
|
+
id: text().primaryKey(),
|
|
34
|
+
accountId: text().notNull(),
|
|
35
|
+
providerId: text().notNull(),
|
|
36
|
+
userId: text()
|
|
37
|
+
.notNull()
|
|
38
|
+
.references(() => staffUsers.id, { onDelete: "cascade" }),
|
|
39
|
+
accessToken: text(),
|
|
40
|
+
refreshToken: text(),
|
|
41
|
+
idToken: text(),
|
|
42
|
+
accessTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }),
|
|
43
|
+
refreshTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }),
|
|
44
|
+
scope: text(),
|
|
45
|
+
password: text(),
|
|
46
|
+
createdAt: createdAt(),
|
|
47
|
+
updatedAt: updatedAt(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const staffVerifications = pgTable("staff_verifications", {
|
|
51
|
+
id: text().primaryKey(),
|
|
52
|
+
identifier: text().notNull(),
|
|
53
|
+
value: text().notNull(),
|
|
54
|
+
expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
|
|
55
|
+
createdAt: createdAt(),
|
|
56
|
+
updatedAt: updatedAt(),
|
|
57
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
2
|
+
import { db } from "#core/db/client";
|
|
3
|
+
import * as staff from "#modules/staff/db/staff.schema";
|
|
4
|
+
|
|
5
|
+
export const staffDatabase = drizzleAdapter(db, {
|
|
6
|
+
provider: "pg",
|
|
7
|
+
schema: {
|
|
8
|
+
staffUser: staff.staffUsers,
|
|
9
|
+
staffSession: staff.staffSessions,
|
|
10
|
+
staffAccount: staff.staffAccounts,
|
|
11
|
+
staffVerification: staff.staffVerifications,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Internal staff accounts: better-auth's tables under staff_* names, apart from users.
|
|
2
|
+
|
|
3
|
+
model StaffUser {
|
|
4
|
+
id String @id @db.VarChar(36)
|
|
5
|
+
name String @db.VarChar(255)
|
|
6
|
+
email String @unique @db.VarChar(255)
|
|
7
|
+
emailVerified Boolean @default(false) @map("email_verified")
|
|
8
|
+
image String? @db.Text
|
|
9
|
+
/// staff role, e.g. "staff"; the admin app requires one
|
|
10
|
+
role String @default("staff") @db.VarChar(64)
|
|
11
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
12
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
13
|
+
sessions StaffSession[]
|
|
14
|
+
accounts StaffAccount[]
|
|
15
|
+
|
|
16
|
+
@@map("staff_users")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
model StaffSession {
|
|
20
|
+
id String @id @db.VarChar(36)
|
|
21
|
+
expiresAt DateTime @map("expires_at")
|
|
22
|
+
token String @unique @db.VarChar(255)
|
|
23
|
+
ipAddress String? @map("ip_address") @db.VarChar(64)
|
|
24
|
+
userAgent String? @map("user_agent") @db.Text
|
|
25
|
+
userId String @map("user_id") @db.VarChar(36)
|
|
26
|
+
user StaffUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
27
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
28
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
29
|
+
|
|
30
|
+
@@map("staff_sessions")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
model StaffAccount {
|
|
34
|
+
id String @id @db.VarChar(36)
|
|
35
|
+
accountId String @map("account_id") @db.VarChar(255)
|
|
36
|
+
providerId String @map("provider_id") @db.VarChar(64)
|
|
37
|
+
userId String @map("user_id") @db.VarChar(36)
|
|
38
|
+
user StaffUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
39
|
+
accessToken String? @map("access_token") @db.Text
|
|
40
|
+
refreshToken String? @map("refresh_token") @db.Text
|
|
41
|
+
idToken String? @map("id_token") @db.Text
|
|
42
|
+
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
|
|
43
|
+
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
|
|
44
|
+
scope String? @db.Text
|
|
45
|
+
password String? @db.Text
|
|
46
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
47
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
48
|
+
|
|
49
|
+
@@map("staff_accounts")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
model StaffVerification {
|
|
53
|
+
id String @id @db.VarChar(36)
|
|
54
|
+
identifier String @db.VarChar(255)
|
|
55
|
+
value String @db.Text
|
|
56
|
+
expiresAt DateTime @map("expires_at")
|
|
57
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
58
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
59
|
+
|
|
60
|
+
@@map("staff_verifications")
|
|
61
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
2
|
+
import { db } from "#core/db/client";
|
|
3
|
+
|
|
4
|
+
/** The staff models in prisma/models/staff.prisma (better-auth's staffUser, staffSession, ...). */
|
|
5
|
+
export const staffDatabase = prismaAdapter(db, { provider: "mysql" });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
2
|
+
import { db } from "#core/db/client";
|
|
3
|
+
|
|
4
|
+
/** The staff models in prisma/models/staff.prisma (better-auth's staffUser, staffSession, ...). */
|
|
5
|
+
export const staffDatabase = prismaAdapter(db, { provider: "postgresql" });
|
|
@@ -7,8 +7,18 @@
|
|
|
7
7
|
"registrationAllowed": false,
|
|
8
8
|
"roles": {
|
|
9
9
|
"realm": [
|
|
10
|
-
{
|
|
11
|
-
|
|
10
|
+
{
|
|
11
|
+
"name": "user",
|
|
12
|
+
"description": "Signed-in user"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "admin",
|
|
16
|
+
"description": "Administrator"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "staff",
|
|
20
|
+
"description": "Internal staff: can use the admin app"
|
|
21
|
+
}
|
|
12
22
|
]
|
|
13
23
|
},
|
|
14
24
|
"clients": [
|
|
@@ -28,9 +38,17 @@
|
|
|
28
38
|
"publicClient": true,
|
|
29
39
|
"standardFlowEnabled": true,
|
|
30
40
|
"directAccessGrantsEnabled": true,
|
|
31
|
-
"redirectUris": [
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
"redirectUris": [
|
|
42
|
+
"http://localhost:5173/*",
|
|
43
|
+
"http://localhost:3000/*"
|
|
44
|
+
],
|
|
45
|
+
"webOrigins": [
|
|
46
|
+
"+"
|
|
47
|
+
],
|
|
48
|
+
"attributes": {
|
|
49
|
+
"pkce.code.challenge.method": "S256",
|
|
50
|
+
"post.logout.redirect.uris": "+"
|
|
51
|
+
},
|
|
34
52
|
"protocolMappers": [
|
|
35
53
|
{
|
|
36
54
|
"name": "api-audience",
|
|
@@ -66,8 +84,18 @@
|
|
|
66
84
|
"firstName": "Alice",
|
|
67
85
|
"lastName": "Admin",
|
|
68
86
|
"enabled": true,
|
|
69
|
-
"credentials": [
|
|
70
|
-
|
|
87
|
+
"credentials": [
|
|
88
|
+
{
|
|
89
|
+
"type": "password",
|
|
90
|
+
"value": "alice",
|
|
91
|
+
"temporary": false
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"realmRoles": [
|
|
95
|
+
"user",
|
|
96
|
+
"admin",
|
|
97
|
+
"staff"
|
|
98
|
+
]
|
|
71
99
|
},
|
|
72
100
|
{
|
|
73
101
|
"username": "bob",
|
|
@@ -76,8 +104,41 @@
|
|
|
76
104
|
"firstName": "Bob",
|
|
77
105
|
"lastName": "User",
|
|
78
106
|
"enabled": true,
|
|
79
|
-
"credentials": [
|
|
80
|
-
|
|
107
|
+
"credentials": [
|
|
108
|
+
{
|
|
109
|
+
"type": "password",
|
|
110
|
+
"value": "bob",
|
|
111
|
+
"temporary": false
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"realmRoles": [
|
|
115
|
+
"user"
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"organizationsEnabled": true,
|
|
120
|
+
"organizations": [
|
|
121
|
+
{
|
|
122
|
+
"name": "Demo School",
|
|
123
|
+
"alias": "demo-school",
|
|
124
|
+
"enabled": true,
|
|
125
|
+
"description": "Dev organization: alice and bob are members",
|
|
126
|
+
"domains": [
|
|
127
|
+
{
|
|
128
|
+
"name": "demo-school.example",
|
|
129
|
+
"verified": false
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
"members": [
|
|
133
|
+
{
|
|
134
|
+
"username": "alice",
|
|
135
|
+
"membershipType": "UNMANAGED"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"username": "bob",
|
|
139
|
+
"membershipType": "UNMANAGED"
|
|
140
|
+
}
|
|
141
|
+
]
|
|
81
142
|
}
|
|
82
143
|
]
|
|
83
144
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { keycloak } from "#
|
|
2
|
+
import { keycloak } from "#modules/users/services/keycloak";
|
|
3
3
|
import { defineCommand } from "@tatara/core";
|
|
4
4
|
import { env } from "#core/config/env";
|
|
5
5
|
|
|
@@ -21,6 +21,8 @@ export default defineCommand({
|
|
|
21
21
|
client_id: client,
|
|
22
22
|
username: user,
|
|
23
23
|
password: password ?? user,
|
|
24
|
+
// the organization scope puts the user's Keycloak organizations in the token
|
|
25
|
+
scope: "openid organization",
|
|
24
26
|
}),
|
|
25
27
|
});
|
|
26
28
|
const body = (await res.json()) as { access_token?: string; error_description?: string };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateKeyPair, SignJWT } from "jose";
|
|
2
2
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { auth } from "#
|
|
4
|
-
import { keycloak } from "#
|
|
3
|
+
import { auth } from "#modules/users/services/auth";
|
|
4
|
+
import { keycloak } from "#modules/users/services/keycloak";
|
|
5
5
|
import { env } from "#core/config/env";
|
|
6
6
|
import { newId } from "@tatara/db";
|
|
7
7
|
import { api } from "#tests/helpers/api";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { auth } from "#
|
|
2
|
+
import { auth } from "#modules/users/services/auth";
|
|
3
3
|
import { newId } from "@tatara/db";
|
|
4
4
|
import { api } from "#tests/helpers/api";
|
|
5
5
|
import { keycloakToken } from "#tests/helpers/auth";
|
|
@@ -13,8 +13,9 @@ beforeEach(async () => {
|
|
|
13
13
|
describe("Keycloak access tokens with authz", () => {
|
|
14
14
|
it("lets token roles match built-in authz roles", async () => {
|
|
15
15
|
await authz.syncSystemRoles();
|
|
16
|
-
|
|
17
|
-
const
|
|
16
|
+
// the token names its org, as Keycloak's organization claim does
|
|
17
|
+
const admin = await keycloakToken({ roles: ["admin"], organization: ["test-org"] });
|
|
18
|
+
const user = await keycloakToken({ roles: ["user"], organization: ["test-org"] });
|
|
18
19
|
expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
|
|
19
20
|
200,
|
|
20
21
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
|
|
2
|
-
import { users } from "#
|
|
2
|
+
import { users } from "#modules/users/db/auth.schema";
|
|
3
3
|
|
|
4
4
|
export const organizations = mysqlTable("organizations", {
|
|
5
5
|
id: varchar({ length: 36 }).primaryKey(),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export const SYSTEM_ROLES = [
|
|
3
3
|
{ key: "admin", name: "Admin", description: "Full access, including roles and members." },
|
|
4
4
|
{ key: "member", name: "Member", description: "Default role for every member." },
|
|
5
|
+
// tatara:slot roles
|
|
5
6
|
] as const;
|
|
6
7
|
|
|
7
8
|
export type SystemRoleKey = (typeof SYSTEM_ROLES)[number]["key"];
|
|
@@ -2,9 +2,12 @@ import { authorizationRoutes } from "@tatara/authz";
|
|
|
2
2
|
import { authz } from "#core/authz/index";
|
|
3
3
|
import type { AppEnv } from "#core/http/context";
|
|
4
4
|
import { defineRoutes } from "#core/http/routes";
|
|
5
|
+
// tatara:slot imports
|
|
5
6
|
|
|
6
7
|
export default defineRoutes({
|
|
7
8
|
app: "api",
|
|
8
9
|
basePath: "/authorization",
|
|
9
|
-
router: authorizationRoutes<AppEnv>(authz
|
|
10
|
+
router: authorizationRoutes<AppEnv>(authz, {
|
|
11
|
+
// tatara:slot route-options
|
|
12
|
+
}),
|
|
10
13
|
});
|
|
@@ -47,7 +47,8 @@ describe("/api/authorization", () => {
|
|
|
47
47
|
{ headers: await asUser({ systemRoles: ["admin"] }) },
|
|
48
48
|
);
|
|
49
49
|
expect(res.status).toBe(200);
|
|
50
|
-
|
|
50
|
+
const builtIn = res.body.data.filter((r) => r.isSystem).map((r) => r.key);
|
|
51
|
+
expect(builtIn).toEqual(expect.arrayContaining(["admin", "member"]));
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
it("rejects custom roles that grant authorization itself", async () => {
|
|
@@ -127,7 +128,8 @@ describe("/api/authorization", () => {
|
|
|
127
128
|
{ headers: await asUser({ systemRoles: ["admin"] }) },
|
|
128
129
|
);
|
|
129
130
|
expect(res.status).toBe(200);
|
|
130
|
-
|
|
131
|
+
// with organizations the member's org role ("member") counts too
|
|
132
|
+
expect(res.body.data.roles).toContain("admin");
|
|
131
133
|
expect(res.body.data.grants.map((g) => g.resource)).toContain("audit_log");
|
|
132
134
|
});
|
|
133
135
|
|
|
@@ -10,6 +10,7 @@ export const authz = createAuthz<PermissionKey>({
|
|
|
10
10
|
store: drizzleAuthzStore(db, tables),
|
|
11
11
|
catalog,
|
|
12
12
|
systemRoles: SYSTEM_ROLES,
|
|
13
|
+
// tatara:slot options
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
|
|
@@ -3,6 +3,8 @@ import type { Logger } from "#core/logging/logger";
|
|
|
3
3
|
export interface Principal {
|
|
4
4
|
userId: string;
|
|
5
5
|
orgId: string | null;
|
|
6
|
+
/** the caller's role in orgId, set by requireOrg() */
|
|
7
|
+
orgRole?: string | null;
|
|
6
8
|
roles: string[];
|
|
7
9
|
kind: "user" | "service";
|
|
8
10
|
claims: Record<string, unknown>;
|