@tatara/cli 0.9.2 → 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.
Files changed (58) hide show
  1. package/dist/bin.mjs +358 -90
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/apps/admin/app.ts +1 -1
  4. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +2 -2
  5. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +11 -4
  6. package/templates/features/admin/files/src/modules/staff/db/seed.ts +27 -0
  7. package/templates/features/admin/files/src/modules/staff/services/auth.ts +46 -0
  8. package/templates/features/admin/files/tests/helpers/staff.ts +8 -0
  9. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/db/staff.schema.ts +58 -0
  10. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/services/adapter.ts +13 -0
  11. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/db/staff.schema.ts +57 -0
  12. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/services/adapter.ts +13 -0
  13. package/templates/features/admin/files+prisma/prisma/models/staff.prisma +61 -0
  14. package/templates/features/admin/files+prisma+mysql/src/modules/staff/services/adapter.ts +5 -0
  15. package/templates/features/admin/files+prisma+postgres/src/modules/staff/services/adapter.ts +5 -0
  16. package/templates/features/auth-keycloak/files/keycloak/realm.json +70 -9
  17. package/templates/features/auth-keycloak/files/src/{core/auth → modules/users}/commands/keycloak-token.ts +3 -1
  18. package/templates/features/auth-keycloak/files/{src/core/auth/tests/keycloak-tokens.test.ts → tests/tatara/auth-keycloak-tokens.test.ts} +2 -2
  19. 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
  20. package/templates/features/auth-keycloak/files+authz/{src/core/auth/tests/keycloak-authz.test.ts → tests/tatara/auth-keycloak-authz.test.ts} +3 -2
  21. package/templates/features/auth-orgs/files+drizzle+mysql/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  22. package/templates/features/auth-orgs/files+drizzle+postgres/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  23. package/templates/features/authz/files/src/core/authz/system-roles.ts +1 -0
  24. package/templates/features/authz/files/src/{core/authz → modules/users}/routes/authorization.routes.ts +4 -1
  25. package/templates/features/authz/files/{src/core/authz/tests → tests/tatara}/authz.test.ts +4 -2
  26. package/templates/features/authz/files+drizzle/src/core/authz/index.ts +1 -0
  27. package/templates/features/authz/files+prisma/src/core/authz/index.ts +1 -0
  28. package/templates/features/base/files/src/apps/api/app.ts +1 -1
  29. package/templates/features/base/files/src/core/http/context.ts +2 -0
  30. package/templates/features/better-auth/files/src/{core/auth → modules/users}/db/seed.ts +1 -1
  31. package/templates/features/better-auth/files/src/modules/users/services/auth.ts +37 -0
  32. package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/delivery.ts +1 -1
  33. package/templates/features/better-auth/files/tests/helpers/auth.ts +4 -3
  34. package/templates/features/better-auth/files+admin/tests/tatara/auth-openapi-admin.test.ts +10 -0
  35. package/templates/features/better-auth/files+auth-orgs/{src/core/auth/tests/principal.test.ts → tests/tatara/auth-principal.test.ts} +1 -1
  36. 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
  37. package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  38. package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  39. package/templates/versions.json +4 -4
  40. package/templates/features/better-auth/files/src/core/auth/auth.ts +0 -26
  41. package/templates/features/better-auth/files/src/core/auth/principal.ts +0 -10
  42. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +0 -8
  43. /package/templates/features/auth/files/src/{core/auth → modules/users}/routes/me.routes.ts +0 -0
  44. /package/templates/features/auth/files/{src/core/auth/tests/me.test.ts → tests/tatara/auth-me.test.ts} +0 -0
  45. /package/templates/features/auth-keycloak/files/src/{core/auth → modules/users/services}/keycloak.ts +0 -0
  46. /package/templates/features/authz/files/{src/core/authz/tests/error-messages.test.ts → tests/tatara/authz-error-messages.test.ts} +0 -0
  47. /package/templates/features/authz/files/{src/core/authz/tests/request-body.test.ts → tests/tatara/authz-request-body.test.ts} +0 -0
  48. /package/templates/features/base/files/src/{core/http/app.ts → apps/http.ts} +0 -0
  49. /package/templates/features/base/files/{src/core/config/tests/env.test.ts → tests/tatara/config-env.test.ts} +0 -0
  50. /package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/sms.ts +0 -0
  51. /package/templates/features/better-auth/files/{src/core/auth/tests/openapi.test.ts → tests/tatara/auth-openapi.test.ts} +0 -0
  52. /package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  53. /package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  54. /package/templates/features/better-auth/files+prisma+mysql/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  55. /package/templates/features/better-auth/files+prisma+postgres/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  56. /package/templates/features/email/files/{src/core/email/tests → tests/tatara}/email.test.ts +0 -0
  57. /package/templates/features/i18n/files/{src/core/i18n/tests → tests/tatara}/i18n.test.ts +0 -0
  58. /package/templates/features/storage/files/{src/core/storage/tests → tests/tatara}/storage.test.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,4 @@
1
- import { createHttpApp } from "#core/http/app";
1
+ import { createHttpApp } from "#apps/http";
2
2
 
3
3
  /** Internal/staff API on its own port; every /api route requires the admin role. */
4
4
  export const app = createHttpApp("admin", { title: "__APP_NAME__ admin API" });
@@ -11,8 +11,8 @@ router.openapi(
11
11
  method: "get",
12
12
  path: "/",
13
13
  tags: ["Example"],
14
- summary: "Greet the signed-in admin",
15
- security: [{ bearerAuth: ["admin"] }],
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("forbids users without the admin role", async () => {
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 admins", async () => {
17
- const res = await adminApi.get("/api/greetings", { headers: await asUser({ roles: ["admin"] }) });
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
- { "name": "user", "description": "Signed-in user" },
11
- { "name": "admin", "description": "Administrator" }
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": ["http://localhost:5173/*", "http://localhost:3000/*"],
32
- "webOrigins": ["+"],
33
- "attributes": { "pkce.code.challenge.method": "S256", "post.logout.redirect.uris": "+" },
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": [{ "type": "password", "value": "alice", "temporary": false }],
70
- "realmRoles": ["user", "admin"]
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": [{ "type": "password", "value": "bob", "temporary": false }],
80
- "realmRoles": ["user"]
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 "#core/auth/keycloak";
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 "#core/auth/auth";
4
- import { keycloak } from "#core/auth/keycloak";
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 "#core/auth/auth";
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
- const admin = await keycloakToken({ roles: ["admin"] });
17
- const user = await keycloakToken({ roles: ["user"] });
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 "#core/auth/db/auth.schema";
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(),
@@ -1,5 +1,5 @@
1
1
  import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
- import { users } from "#core/auth/db/auth.schema";
2
+ import { users } from "#modules/users/db/auth.schema";
3
3
 
4
4
  export const organizations = pgTable("organizations", {
5
5
  id: text().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
- expect(res.body.data.filter((r) => r.isSystem).map((r) => r.key)).toEqual(["admin", "member"]);
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
- expect(res.body.data.roles).toEqual(["admin"]);
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;
@@ -9,6 +9,7 @@ export const authz = createAuthz<PermissionKey>({
9
9
  store: prismaAuthzStore(db),
10
10
  catalog,
11
11
  systemRoles: SYSTEM_ROLES,
12
+ // tatara:slot options
12
13
  });
13
14
 
14
15
  export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -1,3 +1,3 @@
1
- import { createHttpApp } from "#core/http/app";
1
+ import { createHttpApp } from "#apps/http";
2
2
 
3
3
  export const app = createHttpApp("api", { title: "__APP_NAME__ API" });
@@ -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>;
@@ -1,4 +1,4 @@
1
- import { auth } from "#core/auth/auth";
1
+ import { auth } from "#modules/users/services/auth";
2
2
  import { env } from "#core/config/env";
3
3
  import { defineSeed } from "@tatara/db";
4
4