@tatara/cli 0.4.0 → 0.6.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 (42) hide show
  1. package/dist/bin.mjs +377 -94
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
  4. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
  5. package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
  6. package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
  7. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +2 -2
  8. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
  9. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
  10. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
  11. package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
  12. package/templates/features/authz/files/src/core/authz/index.ts +9 -0
  13. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
  14. package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
  15. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
  16. package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
  17. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
  18. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
  19. package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
  20. package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
  21. package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
  22. package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
  23. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  24. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  25. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  26. package/templates/versions.json +4 -1
  27. package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
  28. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
  29. package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
  30. package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
  31. package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
  32. package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
  33. package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
  34. package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
  35. package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
  36. package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
  37. package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
  38. package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
  39. package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
  40. package/templates/features/authz/files/src/core/authz/types.ts +0 -87
  41. package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
  42. package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
@@ -1,84 +0,0 @@
1
- import { invalidateMember } from "#core/authz/cache";
2
- import { type OverlayEffect, type Scope, SYSTEM_ACTOR } from "#core/authz/constants";
3
- import { store } from "#core/authz/db/store";
4
- import { audit } from "#core/authz/services/audit";
5
- import { validateGrants } from "#core/authz/services/grants";
6
- import { NotFoundError } from "@tatara/http";
7
-
8
- export async function getMember(orgId: string, userId: string) {
9
- const [roles, overlays] = await Promise.all([
10
- store.memberRoles(orgId, userId),
11
- store.listOverlays(orgId, userId),
12
- ]);
13
- return { userId, orgId, roles, overlays };
14
- }
15
-
16
- export async function assignRole(actorId: string | null, orgId: string, userId: string, roleId: string) {
17
- const role = await store.findRole(orgId, roleId);
18
- if (!role) throw new NotFoundError("Role");
19
- const added = await store.assignRole(orgId, userId, roleId, actorId);
20
- invalidateMember(userId, orgId);
21
- if (added && actorId) {
22
- await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_assigned", after: { roleId, key: role.key } });
23
- }
24
- return added;
25
- }
26
-
27
- export async function revokeRole(actorId: string, orgId: string, userId: string, roleId: string) {
28
- const removed = await store.revokeRole(orgId, userId, roleId);
29
- if (!removed) throw new NotFoundError("Role assignment");
30
- invalidateMember(userId, orgId);
31
- await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_revoked", before: { roleId } });
32
- }
33
-
34
- /** Removes all of a user's access in an org; called when they stop being a member. */
35
- export async function removeMemberAccess(orgId: string, userId: string, actorId = SYSTEM_ACTOR) {
36
- const removed = await store.removeMember(orgId, userId);
37
- invalidateMember(userId, orgId);
38
- if (removed.roles + removed.overlays > 0) {
39
- await audit({
40
- orgId,
41
- actorId,
42
- targetUserId: userId,
43
- changeType: "member_removed",
44
- before: removed,
45
- });
46
- }
47
- return removed;
48
- }
49
-
50
- export interface OverlayInput {
51
- resource: string;
52
- action: string;
53
- scope?: Scope | null;
54
- effect: OverlayEffect;
55
- reason?: string | null;
56
- expiresAt?: Date | null;
57
- }
58
-
59
- /** Per-user grant or deny, optionally expiring. Deny always wins over any grant. */
60
- export async function addOverlay(actorId: string | null, orgId: string, userId: string, input: OverlayInput) {
61
- const [grant] = validateGrants([input], { allowMeta: input.effect === "deny" });
62
- if (!grant) throw new NotFoundError("Permission");
63
- const id = await store.addOverlay({
64
- orgId,
65
- userId,
66
- ...grant,
67
- effect: input.effect,
68
- grantedBy: actorId,
69
- reason: input.reason ?? null,
70
- expiresAt: input.expiresAt ?? null,
71
- });
72
- invalidateMember(userId, orgId);
73
- if (actorId) {
74
- await audit({ orgId, actorId, targetUserId: userId, changeType: "overlay_granted", after: { id, ...grant, effect: input.effect } });
75
- }
76
- return id;
77
- }
78
-
79
- export async function removeOverlay(actorId: string, orgId: string, userId: string, overlayId: string) {
80
- const removed = await store.removeOverlay(orgId, userId, overlayId);
81
- if (!removed) throw new NotFoundError("Overlay");
82
- invalidateMember(userId, orgId);
83
- await audit({ orgId, actorId, targetUserId: userId, changeType: "overlay_revoked", before: removed });
84
- }
@@ -1,73 +0,0 @@
1
- import { invalidateAll } from "#core/authz/cache";
2
- import { store } from "#core/authz/db/store";
3
- import { audit } from "#core/authz/services/audit";
4
- import { validateGrants } from "#core/authz/services/grants";
5
- import { SYSTEM_ROLE_KEYS } from "#core/authz/system-roles";
6
- import type { RoleRow } from "#core/authz/types";
7
- import { BusinessRuleError, ConflictError, NotFoundError } from "@tatara/http";
8
-
9
- export interface RoleInput {
10
- key: string;
11
- name: string;
12
- description?: string | null;
13
- grants: { resource: string; action: string; scope?: string | null }[];
14
- }
15
-
16
- export function listRoles(orgId: string): Promise<RoleRow[]> {
17
- return store.listRoles(orgId);
18
- }
19
-
20
- async function customRole(orgId: string, roleId: string): Promise<RoleRow> {
21
- const role = await store.findRole(orgId, roleId);
22
- if (!role) throw new NotFoundError("Role");
23
- if (role.isSystem) throw new BusinessRuleError("SYSTEM_ROLE_IMMUTABLE", "Built-in roles cannot be changed");
24
- return role;
25
- }
26
-
27
- export async function createRole(actorId: string, orgId: string, input: RoleInput): Promise<RoleRow> {
28
- if (SYSTEM_ROLE_KEYS.has(input.key)) {
29
- throw new ConflictError(`"${input.key}" is a built-in role key`);
30
- }
31
- if (await store.findRoleByKey(orgId, input.key)) throw new ConflictError(`Role "${input.key}" exists`);
32
- const grants = validateGrants(input.grants, { allowMeta: false });
33
- const id = await store.createRole({
34
- orgId,
35
- key: input.key,
36
- name: input.name,
37
- description: input.description ?? null,
38
- isSystem: false,
39
- grants,
40
- });
41
- const role = await store.findRole(orgId, id);
42
- if (!role) throw new NotFoundError("Role");
43
- await audit({ orgId, actorId, changeType: "role_created", after: role });
44
- return role;
45
- }
46
-
47
- export async function updateRole(
48
- actorId: string,
49
- orgId: string,
50
- roleId: string,
51
- input: Partial<Omit<RoleInput, "key">>,
52
- ): Promise<RoleRow> {
53
- const before = await customRole(orgId, roleId);
54
- await store.updateRole(roleId, {
55
- ...(input.name === undefined ? {} : { name: input.name }),
56
- ...(input.description === undefined ? {} : { description: input.description }),
57
- ...(input.grants ? { grants: validateGrants(input.grants, { allowMeta: false }) } : {}),
58
- });
59
- invalidateAll();
60
- const after = await customRole(orgId, roleId);
61
- await audit({ orgId, actorId, changeType: "role_updated", before, after });
62
- return after;
63
- }
64
-
65
- export async function deleteRole(actorId: string, orgId: string, roleId: string): Promise<void> {
66
- const role = await customRole(orgId, roleId);
67
- if (role.memberCount > 0) {
68
- throw new ConflictError(`Role "${role.key}" is assigned to ${role.memberCount} member(s)`);
69
- }
70
- await store.deleteRole(roleId);
71
- invalidateAll();
72
- await audit({ orgId, actorId, changeType: "role_deleted", before: role });
73
- }
@@ -1,34 +0,0 @@
1
- import { invalidateAll } from "#core/authz/cache";
2
- import { SYSTEM_ORG_ID } from "#core/authz/constants";
3
- import { store } from "#core/authz/db/store";
4
- import { validateGrants } from "#core/authz/services/grants";
5
- import { SYSTEM_ROLES } from "#core/authz/system-roles";
6
- import { grantKey } from "#core/authz/types";
7
- import { catalog } from "#generated/permissions";
8
-
9
- /** Creates or updates every built-in role so its grants match the code. Idempotent. */
10
- export async function syncSystemRoles(): Promise<{ created: string[]; updated: string[] }> {
11
- const created: string[] = [];
12
- const updated: string[] = [];
13
- for (const meta of SYSTEM_ROLES) {
14
- const grants = validateGrants(
15
- (catalog.roleGrants[meta.key] ?? []).map(([resource, action, scope]) => ({ resource, action, scope })),
16
- { allowMeta: true },
17
- );
18
- const existing = await store.findRoleByKey(SYSTEM_ORG_ID, meta.key);
19
- if (!existing) {
20
- await store.createRole({ orgId: SYSTEM_ORG_ID, key: meta.key, name: meta.name, description: meta.description, isSystem: true, grants });
21
- created.push(meta.key);
22
- continue;
23
- }
24
- const want = new Set(grants.map(grantKey));
25
- const have = new Set(existing.grants.map(grantKey));
26
- const same = want.size === have.size && [...want].every((k) => have.has(k));
27
- if (!same || existing.name !== meta.name || existing.description !== meta.description) {
28
- await store.updateRole(existing.id, { name: meta.name, description: meta.description, grants });
29
- updated.push(meta.key);
30
- }
31
- }
32
- if (created.length || updated.length) invalidateAll();
33
- return { created, updated };
34
- }
@@ -1,87 +0,0 @@
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
- }
@@ -1,99 +0,0 @@
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
- });
@@ -1,60 +0,0 @@
1
- import type { OpenAPIHono } from "@hono/zod-openapi";
2
- import { auth } from "#core/auth/auth";
3
-
4
- type JsonObject = Record<string, unknown>;
5
- type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
6
- type Paths = NonNullable<OpenApiDocument["paths"]>;
7
- type Components = NonNullable<OpenApiDocument["components"]>;
8
-
9
- interface AuthDocument {
10
- paths?: Record<string, JsonObject>;
11
- components?: { schemas?: JsonObject; securitySchemes?: JsonObject };
12
- }
13
-
14
- const SCHEMA_REF = "#/components/schemas/";
15
-
16
- // better-auth names schemas like the app (User, Member, …); prefix them so neither side is overwritten
17
- function renameRefs(value: unknown, rename: (name: string) => string): AuthDocument {
18
- return JSON.parse(
19
- JSON.stringify(value).replaceAll(
20
- new RegExp(`"\\$ref":"${SCHEMA_REF}([^"]+)"`, "g"),
21
- (_match, name: string) => `"$ref":"${SCHEMA_REF}${rename(name)}"`,
22
- ),
23
- ) as AuthDocument;
24
- }
25
-
26
- function retag(paths: Record<string, JsonObject>, tag: string): Record<string, JsonObject> {
27
- for (const operations of Object.values(paths)) {
28
- for (const operation of Object.values(operations as Record<string, JsonObject>)) {
29
- if (operation && typeof operation === "object") operation.tags = [tag];
30
- }
31
- }
32
- return paths;
33
- }
34
-
35
- /** Adds better-auth's endpoints (served under `basePath`) to the app's OpenAPI document. */
36
- export async function mergeAuthOpenApi(document: OpenApiDocument, basePath: string): Promise<void> {
37
- const authDoc = renameRefs(await auth.api.generateOpenAPISchema(), (name) => `Auth${name}`);
38
-
39
- document.paths ??= {};
40
- for (const [path, operations] of Object.entries(retag(authDoc.paths ?? {}, "Auth"))) {
41
- document.paths[`${basePath}${path}`] = operations as Paths[string];
42
- }
43
-
44
- document.components ??= {};
45
- document.components.schemas ??= {};
46
- for (const [name, schema] of Object.entries(authDoc.components?.schemas ?? {})) {
47
- document.components.schemas[`Auth${name}`] = schema as NonNullable<
48
- Components["schemas"]
49
- >[string];
50
- }
51
- document.components.securitySchemes = {
52
- ...(authDoc.components?.securitySchemes as Components["securitySchemes"]),
53
- ...document.components.securitySchemes,
54
- };
55
-
56
- document.tags = [
57
- ...(document.tags ?? []),
58
- { name: "Auth", description: "Sign-up, sign-in, sessions, organizations (better-auth)" },
59
- ];
60
- }