@tatara/cli 0.0.0-stage → 0.1.1

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.mjs +3001 -0
  3. package/package.json +32 -3
  4. package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
  5. package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
  6. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
  7. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
  8. package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
  9. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
  10. package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
  11. package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
  12. package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
  13. package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
  14. package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
  15. package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
  16. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
  17. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
  18. package/templates/features/authz/files/src/core/authz/constants.ts +32 -0
  19. package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
  20. package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
  21. package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
  22. package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
  23. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
  24. package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
  25. package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
  26. package/templates/features/authz/files/src/core/authz/services/members.ts +84 -0
  27. package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
  28. package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
  29. package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
  30. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
  31. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  32. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  33. package/templates/features/authz/files/src/core/authz/types.ts +87 -0
  34. package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
  35. package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
  36. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +275 -0
  37. package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
  38. package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
  39. package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
  40. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +236 -0
  41. package/templates/features/base/files/AGENTS.md +71 -0
  42. package/templates/features/base/files/CLAUDE.md +1 -0
  43. package/templates/features/base/files/Dockerfile +16 -0
  44. package/templates/features/base/files/README.md +31 -0
  45. package/templates/features/base/files/_github/workflows/ci.yml +26 -0
  46. package/templates/features/base/files/biome.template.json +20 -0
  47. package/templates/features/base/files/dockerignore +8 -0
  48. package/templates/features/base/files/gitignore +9 -0
  49. package/templates/features/base/files/src/apps/api/app.ts +3 -0
  50. package/templates/features/base/files/src/apps/api/server.ts +5 -0
  51. package/templates/features/base/files/src/apps/cli/index.ts +6 -0
  52. package/templates/features/base/files/src/core/commands/define.ts +33 -0
  53. package/templates/features/base/files/src/core/commands/runner.ts +54 -0
  54. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  55. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  56. package/templates/features/base/files/src/core/http/app.ts +57 -0
  57. package/templates/features/base/files/src/core/http/context.ts +18 -0
  58. package/templates/features/base/files/src/core/http/errors.ts +79 -0
  59. package/templates/features/base/files/src/core/http/health.ts +25 -0
  60. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
  61. package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
  62. package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
  63. package/templates/features/base/files/src/core/http/response.ts +72 -0
  64. package/templates/features/base/files/src/core/http/routes.ts +28 -0
  65. package/templates/features/base/files/src/core/http/server.ts +15 -0
  66. package/templates/features/base/files/src/core/logging/logger.ts +17 -0
  67. package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
  68. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
  69. package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
  70. package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
  71. package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
  72. package/templates/features/base/files/tests/helpers/api.ts +54 -0
  73. package/templates/features/base/files/tests/setup.ts +2 -0
  74. package/templates/features/base/files/tsconfig.json +20 -0
  75. package/templates/features/base/files/tsdown.config.ts +15 -0
  76. package/templates/features/base/files/vitest.config.ts +16 -0
  77. package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
  78. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
  79. package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
  80. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  81. package/templates/features/better-auth/files/src/core/auth/principal.ts +40 -0
  82. package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
  83. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  84. package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  86. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  87. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  88. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
  89. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
  90. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
  91. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
  92. package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
  93. package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
  94. package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
  95. package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
  96. package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
  97. package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
  98. package/templates/features/db/files/src/core/db/id.ts +13 -0
  99. package/templates/features/db/files/src/core/db/seed.ts +14 -0
  100. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  101. package/templates/features/db/files/tests/global-setup.ts +27 -0
  102. package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
  103. package/templates/features/db/files/tests/helpers/db.ts +7 -0
  104. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
  105. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
  106. package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
  107. package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
  108. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
  109. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
  110. package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
  111. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
  112. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
  113. package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
  114. package/templates/features/email/files/src/core/email/send.ts +22 -0
  115. package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
  116. package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
  117. package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
  118. package/templates/features/i18n/files/src/locales/ar.json +10 -0
  119. package/templates/features/i18n/files/src/locales/en.json +10 -0
  120. package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
  121. package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
  122. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
  123. package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
  124. package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
  125. package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
  126. package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
  127. package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
  128. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
  129. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
  130. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
  131. package/templates/features/keycloak/files/keycloak/realm.json +83 -0
  132. package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
  133. package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
  134. package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
  135. package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
  136. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
  137. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
  138. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
  139. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
  140. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
  141. package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
  142. package/templates/features/prisma/files/prisma.config.ts +10 -0
  143. package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
  144. package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
  145. package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
  146. package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
  147. package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
  148. package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
  149. package/templates/features/storage/files/src/core/storage/index.ts +8 -0
  150. package/templates/features/storage/files/src/core/storage/local.ts +34 -0
  151. package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
  152. package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
  153. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
  154. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
  155. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
  156. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
  157. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
  158. package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
  159. package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
  160. package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
  161. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
  162. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +127 -0
  163. package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
  164. package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
  165. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
  166. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
  167. package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  168. package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  169. package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  170. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
  171. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
  172. package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
  173. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
  174. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
  175. package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  176. package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  177. package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  178. package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
  179. package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
  180. package/templates/versions.json +34 -0
  181. package/README.md +0 -3
@@ -0,0 +1,34 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+ import { asUser, authenticate } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ describe("error messages", () => {
11
+ it("keeps a specific message instead of the generic one for its code", async () => {
12
+ const headers = await asUser({ systemRoles: ["admin"] });
13
+ const role = await api.post<{ data: { id: string } }>(
14
+ "/api/authorization/roles",
15
+ { key: "auditor", name: "Auditor", grants: [] },
16
+ { headers },
17
+ );
18
+ const member = await authenticate();
19
+ await api.post(
20
+ `/api/authorization/members/${member.userId}/roles`,
21
+ { roleId: role.body.data.id },
22
+ { headers },
23
+ );
24
+ const res = await api.delete<{ code: string; error: string }>(
25
+ `/api/authorization/roles/${role.body.data.id}`,
26
+ { headers },
27
+ );
28
+ expect(res.status).toBe(409);
29
+ expect(res.body).toMatchObject({
30
+ code: "CONFLICT",
31
+ error: 'Role "auditor" is assigned to 1 member(s)',
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,27 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { app } from "#apps/api/app";
3
+ import { asUser } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ // A body sent without Content-Type used to skip validation and reach services as {}
11
+ describe("JSON request bodies", () => {
12
+ it("rejects a missing body with 400", async () => {
13
+ const headers = await asUser({ systemRoles: ["admin"] });
14
+ const res = await app.request("/api/authorization/roles", { method: "POST", headers });
15
+ expect(res.status).toBe(400);
16
+ expect(await res.json()).toMatchObject({ code: "VALIDATION_ERROR" });
17
+ });
18
+
19
+ it("does not assign a role from a missing body", async () => {
20
+ const headers = await asUser({ systemRoles: ["admin"] });
21
+ const res = await app.request("/api/authorization/members/someone/roles", {
22
+ method: "POST",
23
+ headers,
24
+ });
25
+ expect(res.status).toBe(400);
26
+ });
27
+ });
@@ -0,0 +1,87 @@
1
+ import type { OverlayEffect, Scope } from "#core/authz/constants";
2
+
3
+ export interface PermissionGrant {
4
+ resource: string;
5
+ action: string;
6
+ scope: Scope | null;
7
+ }
8
+
9
+ export interface OverlayRow extends PermissionGrant {
10
+ id: string;
11
+ orgId: string;
12
+ userId: string;
13
+ effect: OverlayEffect;
14
+ grantedBy: string | null;
15
+ reason: string | null;
16
+ expiresAt: Date | null;
17
+ createdAt: Date;
18
+ }
19
+
20
+ export interface RoleRow {
21
+ id: string;
22
+ orgId: string;
23
+ key: string;
24
+ name: string;
25
+ description: string | null;
26
+ isSystem: boolean;
27
+ grants: PermissionGrant[];
28
+ memberCount: number;
29
+ }
30
+
31
+ export interface AuditRow {
32
+ id: string;
33
+ orgId: string;
34
+ actorId: string;
35
+ targetUserId: string | null;
36
+ changeType: string;
37
+ before: unknown;
38
+ after: unknown;
39
+ at: Date;
40
+ }
41
+
42
+ /** A member's resolved permissions in one org. */
43
+ export interface EffectivePermissions {
44
+ grants: PermissionGrant[];
45
+ /** "resource:action:scope|*" keys denied by overlays; deny always wins */
46
+ denies: Set<string>;
47
+ roles: string[];
48
+ }
49
+
50
+ export function grantKey(grant: PermissionGrant): string {
51
+ return `${grant.resource}:${grant.action}:${grant.scope ?? "*"}`;
52
+ }
53
+
54
+ /** Everything authz reads and writes; implemented once per ORM in #core/authz/db/store. */
55
+ export interface AuthzStore {
56
+ roleGrants(orgId: string, userId: string, impliedRoleKeys: string[]): Promise<{
57
+ roles: string[];
58
+ grants: PermissionGrant[];
59
+ }>;
60
+ activeOverlays(orgId: string, userId: string, now: Date): Promise<OverlayRow[]>;
61
+ listRoles(orgId: string): Promise<RoleRow[]>;
62
+ findRole(orgId: string, roleId: string): Promise<RoleRow | null>;
63
+ findRoleByKey(orgId: string, key: string): Promise<RoleRow | null>;
64
+ createRole(input: {
65
+ orgId: string;
66
+ key: string;
67
+ name: string;
68
+ description: string | null;
69
+ isSystem: boolean;
70
+ grants: PermissionGrant[];
71
+ }): Promise<string>;
72
+ updateRole(
73
+ roleId: string,
74
+ input: { name?: string; description?: string | null; grants?: PermissionGrant[] },
75
+ ): Promise<void>;
76
+ deleteRole(roleId: string): Promise<void>;
77
+ memberRoles(orgId: string, userId: string): Promise<RoleRow[]>;
78
+ assignRole(orgId: string, userId: string, roleId: string, by: string | null): Promise<boolean>;
79
+ revokeRole(orgId: string, userId: string, roleId: string): Promise<boolean>;
80
+ /** Deletes every role assignment and overlay the user has in the org. */
81
+ removeMember(orgId: string, userId: string): Promise<{ roles: number; overlays: number }>;
82
+ listOverlays(orgId: string, userId: string): Promise<OverlayRow[]>;
83
+ addOverlay(input: Omit<OverlayRow, "id" | "createdAt">): Promise<string>;
84
+ removeOverlay(orgId: string, userId: string, overlayId: string): Promise<OverlayRow | null>;
85
+ writeAudit(input: Omit<AuditRow, "id" | "at">): Promise<void>;
86
+ listAudit(orgId: string, limit: number): Promise<AuditRow[]>;
87
+ }
@@ -0,0 +1,99 @@
1
+ import { z } from "@hono/zod-openapi";
2
+ import { OVERLAY_EFFECTS, SCOPES } from "#core/authz/constants";
3
+
4
+ export const grantSchema = z
5
+ .object({
6
+ resource: z.string().min(1),
7
+ action: z.string().min(1),
8
+ scope: z.enum(SCOPES).nullable().optional(),
9
+ })
10
+ .openapi("PermissionGrant");
11
+
12
+ export const roleSchema = z
13
+ .object({
14
+ id: z.string(),
15
+ key: z.string(),
16
+ name: z.string(),
17
+ description: z.string().nullable(),
18
+ isSystem: z.boolean(),
19
+ grants: z.array(grantSchema),
20
+ memberCount: z.number().int(),
21
+ })
22
+ .openapi("Role");
23
+
24
+ export const createRoleSchema = z
25
+ .object({
26
+ key: z
27
+ .string()
28
+ .regex(/^[a-z][a-z0-9_]{1,62}$/, "lowercase letters, digits and underscores"),
29
+ name: z.string().trim().min(1).max(120),
30
+ description: z.string().max(1000).nullable().optional(),
31
+ grants: z.array(grantSchema).default([]),
32
+ })
33
+ .openapi("CreateRole");
34
+
35
+ export const updateRoleSchema = createRoleSchema.omit({ key: true }).partial().openapi("UpdateRole");
36
+
37
+ export const overlayInputSchema = z
38
+ .object({
39
+ resource: z.string().min(1),
40
+ action: z.string().min(1),
41
+ scope: z.enum(SCOPES).nullable().optional(),
42
+ effect: z.enum(OVERLAY_EFFECTS),
43
+ reason: z.string().max(500).nullable().optional(),
44
+ expiresAt: z.iso.datetime().nullable().optional(),
45
+ })
46
+ .openapi("OverlayInput");
47
+
48
+ export const overlaySchema = z
49
+ .object({
50
+ id: z.string(),
51
+ resource: z.string(),
52
+ action: z.string(),
53
+ scope: z.enum(SCOPES).nullable(),
54
+ effect: z.enum(OVERLAY_EFFECTS),
55
+ reason: z.string().nullable(),
56
+ expiresAt: z.string().nullable(),
57
+ })
58
+ .openapi("Overlay");
59
+
60
+ export const memberSchema = z
61
+ .object({
62
+ userId: z.string(),
63
+ orgId: z.string(),
64
+ roles: z.array(roleSchema),
65
+ overlays: z.array(overlaySchema),
66
+ })
67
+ .openapi("Member");
68
+
69
+ export const myPermissionsSchema = z
70
+ .object({
71
+ orgId: z.string(),
72
+ roles: z.array(z.string()),
73
+ grants: z.array(grantSchema),
74
+ denies: z.array(z.string()),
75
+ })
76
+ .openapi("MyPermissions");
77
+
78
+ export const auditEntrySchema = z
79
+ .object({
80
+ id: z.string(),
81
+ actorId: z.string(),
82
+ targetUserId: z.string().nullable(),
83
+ changeType: z.string(),
84
+ before: z.unknown(),
85
+ after: z.unknown(),
86
+ at: z.string(),
87
+ })
88
+ .openapi("AuditEntry");
89
+
90
+ export const idParams = z.object({ id: z.string().openapi({ param: { name: "id", in: "path" } }) });
91
+ export const userParams = z.object({
92
+ userId: z.string().openapi({ param: { name: "userId", in: "path" } }),
93
+ });
94
+ export const userRoleParams = userParams.extend({
95
+ roleId: z.string().openapi({ param: { name: "roleId", in: "path" } }),
96
+ });
97
+ export const userOverlayParams = userParams.extend({
98
+ overlayId: z.string().openapi({ param: { name: "overlayId", in: "path" } }),
99
+ });
@@ -0,0 +1,30 @@
1
+ import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "#core/authz/constants";
2
+ import { store } from "#core/authz/db/store";
3
+ import { addOverlay, assignRole } from "#core/authz/services/members";
4
+ import { syncSystemRoles } from "#core/authz/services/sync";
5
+ import type { SystemRoleKey } from "#core/authz/system-roles";
6
+ import type { PermissionKey } from "#generated/permissions";
7
+
8
+ export interface TestAccess {
9
+ /** unscoped permissions granted directly (as overlays) */
10
+ permissions?: PermissionKey[];
11
+ /** built-in roles assigned in the org */
12
+ systemRoles?: SystemRoleKey[];
13
+ orgId?: string;
14
+ }
15
+
16
+ export async function applyTestAccess(userId: string, access: TestAccess): Promise<void> {
17
+ const orgId = access.orgId ?? DEFAULT_ORG_ID;
18
+ for (const key of access.permissions ?? []) {
19
+ const [resource = "", action = ""] = key.split(":");
20
+ await addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
21
+ }
22
+ if (access.systemRoles?.length) {
23
+ await syncSystemRoles();
24
+ for (const roleKey of access.systemRoles) {
25
+ const role = await store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
26
+ if (!role) throw new Error(`Unknown built-in role ${roleKey}`);
27
+ await assignRole(null, orgId, userId, role.id);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,275 @@
1
+ import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
2
+ import type { OverlayEffect, Scope } from "#core/authz/constants";
3
+ import { SYSTEM_ORG_ID } from "#core/authz/constants";
4
+ import {
5
+ authzAuditLog,
6
+ authzMemberRoles,
7
+ authzOverlays,
8
+ authzRolePermissions,
9
+ authzRoles,
10
+ } from "#core/authz/db/authz.schema";
11
+ import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
12
+ import { db } from "#core/db/client";
13
+ import { newId } from "#core/db/id";
14
+
15
+ const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
16
+
17
+ type RoleRecord = typeof authzRoles.$inferSelect;
18
+ type OverlayRecord = typeof authzOverlays.$inferSelect;
19
+
20
+ function toOverlay(row: OverlayRecord): OverlayRow {
21
+ return { ...row, scope: toScope(row.scope), effect: row.effect as OverlayEffect };
22
+ }
23
+
24
+ async function hydrate(roles: RoleRecord[], orgId: string): Promise<RoleRow[]> {
25
+ if (roles.length === 0) return [];
26
+ const ids = roles.map((r) => r.id);
27
+ const [grants, members] = await Promise.all([
28
+ db.select().from(authzRolePermissions).where(inArray(authzRolePermissions.roleId, ids)),
29
+ db
30
+ .select({ roleId: authzMemberRoles.roleId, total: count() })
31
+ .from(authzMemberRoles)
32
+ .where(and(eq(authzMemberRoles.orgId, orgId), inArray(authzMemberRoles.roleId, ids)))
33
+ .groupBy(authzMemberRoles.roleId),
34
+ ]);
35
+ return roles.map((role) => ({
36
+ id: role.id,
37
+ orgId: role.orgId,
38
+ key: role.key,
39
+ name: role.name,
40
+ description: role.description,
41
+ isSystem: role.isSystem,
42
+ grants: grants
43
+ .filter((g) => g.roleId === role.id)
44
+ .map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
45
+ memberCount: members.find((m) => m.roleId === role.id)?.total ?? 0,
46
+ }));
47
+ }
48
+
49
+ const visibleTo = (orgId: string) => or(eq(authzRoles.orgId, SYSTEM_ORG_ID), eq(authzRoles.orgId, orgId));
50
+
51
+ async function insertGrants(roleId: string, grants: PermissionGrant[]) {
52
+ if (grants.length === 0) return;
53
+ await db.insert(authzRolePermissions).values(
54
+ grants.map((g) => ({
55
+ id: newId(),
56
+ roleId,
57
+ resource: g.resource,
58
+ action: g.action,
59
+ scope: g.scope ?? "",
60
+ })),
61
+ );
62
+ }
63
+
64
+ export const store: AuthzStore = {
65
+ async roleGrants(orgId, userId, impliedRoleKeys) {
66
+ const assigned = await db
67
+ .select({ roleId: authzMemberRoles.roleId })
68
+ .from(authzMemberRoles)
69
+ .where(and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId)));
70
+ const implied = impliedRoleKeys.length
71
+ ? await db
72
+ .select({ id: authzRoles.id })
73
+ .from(authzRoles)
74
+ .where(and(eq(authzRoles.orgId, SYSTEM_ORG_ID), inArray(authzRoles.key, impliedRoleKeys)))
75
+ : [];
76
+ const roleIds = [...new Set([...assigned.map((r) => r.roleId), ...implied.map((r) => r.id)])];
77
+ if (roleIds.length === 0) return { roles: [], grants: [] };
78
+ const [roles, grants] = await Promise.all([
79
+ db.select({ key: authzRoles.key }).from(authzRoles).where(inArray(authzRoles.id, roleIds)),
80
+ db.select().from(authzRolePermissions).where(inArray(authzRolePermissions.roleId, roleIds)),
81
+ ]);
82
+ return {
83
+ roles: roles.map((r) => r.key),
84
+ grants: grants.map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
85
+ };
86
+ },
87
+
88
+ async activeOverlays(orgId, userId, now) {
89
+ const rows = await db
90
+ .select()
91
+ .from(authzOverlays)
92
+ .where(
93
+ and(
94
+ eq(authzOverlays.orgId, orgId),
95
+ eq(authzOverlays.userId, userId),
96
+ or(isNull(authzOverlays.expiresAt), gt(authzOverlays.expiresAt, now)),
97
+ ),
98
+ );
99
+ return rows.map(toOverlay);
100
+ },
101
+
102
+ async listRoles(orgId) {
103
+ const roles = await db.select().from(authzRoles).where(visibleTo(orgId)).orderBy(authzRoles.key);
104
+ return hydrate(roles, orgId);
105
+ },
106
+
107
+ async findRole(orgId, roleId) {
108
+ const roles = await db
109
+ .select()
110
+ .from(authzRoles)
111
+ .where(and(eq(authzRoles.id, roleId), visibleTo(orgId)));
112
+ return (await hydrate(roles, orgId))[0] ?? null;
113
+ },
114
+
115
+ async findRoleByKey(orgId, key) {
116
+ const roles = await db
117
+ .select()
118
+ .from(authzRoles)
119
+ .where(and(eq(authzRoles.key, key), eq(authzRoles.orgId, orgId)));
120
+ return (await hydrate(roles, orgId))[0] ?? null;
121
+ },
122
+
123
+ async createRole(input) {
124
+ const id = newId();
125
+ await db.insert(authzRoles).values({
126
+ id,
127
+ orgId: input.orgId,
128
+ key: input.key,
129
+ name: input.name,
130
+ description: input.description,
131
+ isSystem: input.isSystem,
132
+ });
133
+ await insertGrants(id, input.grants);
134
+ return id;
135
+ },
136
+
137
+ async updateRole(roleId, input) {
138
+ const fields = {
139
+ ...(input.name === undefined ? {} : { name: input.name }),
140
+ ...(input.description === undefined ? {} : { description: input.description }),
141
+ };
142
+ await db
143
+ .update(authzRoles)
144
+ .set({ ...fields, updatedAt: new Date() })
145
+ .where(eq(authzRoles.id, roleId));
146
+ if (input.grants) {
147
+ await db.delete(authzRolePermissions).where(eq(authzRolePermissions.roleId, roleId));
148
+ await insertGrants(roleId, input.grants);
149
+ }
150
+ },
151
+
152
+ async deleteRole(roleId) {
153
+ await db.delete(authzRoles).where(eq(authzRoles.id, roleId));
154
+ },
155
+
156
+ async memberRoles(orgId, userId) {
157
+ const assigned = await db
158
+ .select({ roleId: authzMemberRoles.roleId })
159
+ .from(authzMemberRoles)
160
+ .where(and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId)));
161
+ if (assigned.length === 0) return [];
162
+ const roles = await db
163
+ .select()
164
+ .from(authzRoles)
165
+ .where(inArray(authzRoles.id, assigned.map((a) => a.roleId)));
166
+ return hydrate(roles, orgId);
167
+ },
168
+
169
+ async assignRole(orgId, userId, roleId, by) {
170
+ const existing = await db
171
+ .select({ id: authzMemberRoles.id })
172
+ .from(authzMemberRoles)
173
+ .where(
174
+ and(
175
+ eq(authzMemberRoles.orgId, orgId),
176
+ eq(authzMemberRoles.userId, userId),
177
+ eq(authzMemberRoles.roleId, roleId),
178
+ ),
179
+ );
180
+ if (existing.length > 0) return false;
181
+ await db.insert(authzMemberRoles).values({ id: newId(), orgId, userId, roleId, assignedBy: by });
182
+ return true;
183
+ },
184
+
185
+ async revokeRole(orgId, userId, roleId) {
186
+ const where = and(
187
+ eq(authzMemberRoles.orgId, orgId),
188
+ eq(authzMemberRoles.userId, userId),
189
+ eq(authzMemberRoles.roleId, roleId),
190
+ );
191
+ const existing = await db.select({ id: authzMemberRoles.id }).from(authzMemberRoles).where(where);
192
+ if (existing.length === 0) return false;
193
+ await db.delete(authzMemberRoles).where(where);
194
+ return true;
195
+ },
196
+
197
+ async removeMember(orgId, userId) {
198
+ const roleWhere = and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId));
199
+ const overlayWhere = and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId));
200
+ // count first: MySQL has no RETURNING
201
+ return db.transaction(async (tx) => {
202
+ const [roles] = await tx.select({ n: count() }).from(authzMemberRoles).where(roleWhere);
203
+ const [overlays] = await tx.select({ n: count() }).from(authzOverlays).where(overlayWhere);
204
+ await tx.delete(authzMemberRoles).where(roleWhere);
205
+ await tx.delete(authzOverlays).where(overlayWhere);
206
+ return { roles: roles?.n ?? 0, overlays: overlays?.n ?? 0 };
207
+ });
208
+ },
209
+
210
+ async listOverlays(orgId, userId) {
211
+ const rows = await db
212
+ .select()
213
+ .from(authzOverlays)
214
+ .where(and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId)))
215
+ .orderBy(desc(authzOverlays.createdAt));
216
+ return rows.map(toOverlay);
217
+ },
218
+
219
+ async addOverlay(input) {
220
+ const id = newId();
221
+ const scope = input.scope ?? "";
222
+ await db
223
+ .delete(authzOverlays)
224
+ .where(
225
+ and(
226
+ eq(authzOverlays.orgId, input.orgId),
227
+ eq(authzOverlays.userId, input.userId),
228
+ eq(authzOverlays.resource, input.resource),
229
+ eq(authzOverlays.action, input.action),
230
+ eq(authzOverlays.scope, scope),
231
+ eq(authzOverlays.effect, input.effect),
232
+ ),
233
+ );
234
+ await db.insert(authzOverlays).values({ ...input, id, scope });
235
+ return id;
236
+ },
237
+
238
+ async removeOverlay(orgId, userId, overlayId) {
239
+ const where = and(
240
+ eq(authzOverlays.orgId, orgId),
241
+ eq(authzOverlays.userId, userId),
242
+ eq(authzOverlays.id, overlayId),
243
+ );
244
+ const [row] = await db.select().from(authzOverlays).where(where);
245
+ if (!row) return null;
246
+ await db.delete(authzOverlays).where(where);
247
+ return toOverlay(row);
248
+ },
249
+
250
+ async writeAudit(input) {
251
+ await db.insert(authzAuditLog).values({
252
+ id: newId(),
253
+ orgId: input.orgId,
254
+ actorId: input.actorId,
255
+ targetUserId: input.targetUserId,
256
+ changeType: input.changeType,
257
+ before: input.before === undefined ? null : JSON.stringify(input.before),
258
+ after: input.after === undefined ? null : JSON.stringify(input.after),
259
+ });
260
+ },
261
+
262
+ async listAudit(orgId, limit) {
263
+ const rows = await db
264
+ .select()
265
+ .from(authzAuditLog)
266
+ .where(eq(authzAuditLog.orgId, orgId))
267
+ .orderBy(desc(authzAuditLog.id))
268
+ .limit(limit);
269
+ return rows.map((r) => ({
270
+ ...r,
271
+ before: r.before ? (JSON.parse(r.before) as unknown) : null,
272
+ after: r.after ? (JSON.parse(r.after) as unknown) : null,
273
+ }));
274
+ },
275
+ };
@@ -0,0 +1,90 @@
1
+ import { boolean, index, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core";
2
+
3
+ const ts = () => timestamp({ fsp: 3, mode: "date" });
4
+ const id = () => varchar({ length: 36 });
5
+ const ref = () => varchar({ length: 255 });
6
+
7
+ /** Built-in roles have orgId "" (they apply to every org); custom roles belong to one org. */
8
+ export const authzRoles = mysqlTable(
9
+ "authz_roles",
10
+ {
11
+ id: id().primaryKey(),
12
+ orgId: ref().notNull().default(""),
13
+ key: varchar({ length: 64 }).notNull(),
14
+ name: varchar({ length: 120 }).notNull(),
15
+ description: text(),
16
+ isSystem: boolean().notNull().default(false),
17
+ createdAt: ts().notNull().defaultNow(),
18
+ updatedAt: ts().notNull().defaultNow(),
19
+ },
20
+ (t) => [uniqueIndex("authz_roles_org_key").on(t.orgId, t.key)],
21
+ );
22
+
23
+ /** scope "" means the action is unscoped (keeps unique keys identical on every database). */
24
+ export const authzRolePermissions = mysqlTable(
25
+ "authz_role_permissions",
26
+ {
27
+ id: id().primaryKey(),
28
+ roleId: id()
29
+ .notNull()
30
+ .references(() => authzRoles.id, { onDelete: "cascade" }),
31
+ resource: varchar({ length: 64 }).notNull(),
32
+ action: varchar({ length: 64 }).notNull(),
33
+ scope: varchar({ length: 16 }).notNull().default(""),
34
+ },
35
+ (t) => [uniqueIndex("authz_role_permissions_grant").on(t.roleId, t.resource, t.action, t.scope)],
36
+ );
37
+
38
+ export const authzMemberRoles = mysqlTable(
39
+ "authz_member_roles",
40
+ {
41
+ id: id().primaryKey(),
42
+ orgId: ref().notNull(),
43
+ userId: ref().notNull(),
44
+ roleId: id()
45
+ .notNull()
46
+ .references(() => authzRoles.id, { onDelete: "cascade" }),
47
+ assignedBy: ref(),
48
+ assignedAt: ts().notNull().defaultNow(),
49
+ },
50
+ (t) => [
51
+ uniqueIndex("authz_member_roles_member_role").on(t.orgId, t.userId, t.roleId),
52
+ index("authz_member_roles_member").on(t.orgId, t.userId),
53
+ ],
54
+ );
55
+
56
+ export const authzOverlays = mysqlTable(
57
+ "authz_overlays",
58
+ {
59
+ id: id().primaryKey(),
60
+ orgId: ref().notNull(),
61
+ userId: ref().notNull(),
62
+ resource: varchar({ length: 64 }).notNull(),
63
+ action: varchar({ length: 64 }).notNull(),
64
+ scope: varchar({ length: 16 }).notNull().default(""),
65
+ effect: varchar({ length: 8 }).notNull(),
66
+ grantedBy: ref(),
67
+ reason: text(),
68
+ expiresAt: ts(),
69
+ createdAt: ts().notNull().defaultNow(),
70
+ },
71
+ (t) => [
72
+ uniqueIndex("authz_overlays_grant").on(t.orgId, t.userId, t.resource, t.action, t.scope, t.effect),
73
+ index("authz_overlays_member").on(t.orgId, t.userId),
74
+ ],
75
+ );
76
+
77
+ export const authzAuditLog = mysqlTable(
78
+ "authz_audit_log",
79
+ {
80
+ id: id().primaryKey(),
81
+ orgId: ref().notNull(),
82
+ actorId: ref().notNull(),
83
+ targetUserId: ref(),
84
+ changeType: varchar({ length: 48 }).notNull(),
85
+ before: text(),
86
+ after: text(),
87
+ at: ts().notNull().defaultNow(),
88
+ },
89
+ (t) => [index("authz_audit_log_org_at").on(t.orgId, t.at)],
90
+ );