@tatara/cli 0.7.0 → 0.9.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 +395 -67
- package/package.json +1 -1
- package/templates/features/authz/files+drizzle/src/core/authz/index.ts +15 -0
- package/templates/features/authz/files+prisma/src/core/authz/index.ts +14 -0
- package/templates/versions.json +10 -10
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +0 -21
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +0 -11
- package/templates/features/authz/files/src/core/authz/db/seed.ts +0 -10
- package/templates/features/authz/files/src/core/authz/index.ts +0 -9
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +0 -275
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +0 -236
- package/templates/features/db/files/src/core/db/commands/migrate.ts +0 -12
- package/templates/features/db/files/src/core/db/commands/reset.ts +0 -19
- package/templates/features/db/files/src/core/db/commands/seed.ts +0 -13
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +0 -7
- package/templates/features/email/files/src/core/email/commands/send-test.ts +0 -13
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +0 -13
- package/templates/features/storage/files/src/core/storage/commands/check.ts +0 -18
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createAuthz } from "@tatara/authz";
|
|
2
|
+
import { drizzleAuthzStore } from "@tatara/authz/drizzle";
|
|
3
|
+
import * as tables from "#core/authz/db/authz.schema";
|
|
4
|
+
import { SYSTEM_ROLES } from "#core/authz/system-roles";
|
|
5
|
+
import { db } from "#core/db/client";
|
|
6
|
+
import { catalog, type PermissionKey } from "#generated/permissions";
|
|
7
|
+
|
|
8
|
+
/** The app's authorization: its tables (authz.schema.ts), the catalog from `tatara sync`, built-in roles. */
|
|
9
|
+
export const authz = createAuthz<PermissionKey>({
|
|
10
|
+
store: drizzleAuthzStore(db, tables),
|
|
11
|
+
catalog,
|
|
12
|
+
systemRoles: SYSTEM_ROLES,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createAuthz } from "@tatara/authz";
|
|
2
|
+
import { prismaAuthzStore } from "@tatara/authz/prisma";
|
|
3
|
+
import { SYSTEM_ROLES } from "#core/authz/system-roles";
|
|
4
|
+
import { db } from "#core/db/client";
|
|
5
|
+
import { catalog, type PermissionKey } from "#generated/permissions";
|
|
6
|
+
|
|
7
|
+
/** The app's authorization: its tables (prisma/models/authz.prisma), the catalog from `tatara sync`, built-in roles. */
|
|
8
|
+
export const authz = createAuthz<PermissionKey>({
|
|
9
|
+
store: prismaAuthzStore(db),
|
|
10
|
+
catalog,
|
|
11
|
+
systemRoles: SYSTEM_ROLES,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
|
package/templates/versions.json
CHANGED
|
@@ -31,15 +31,15 @@
|
|
|
31
31
|
"@aws-sdk/s3-request-presigner": "3.1152.0",
|
|
32
32
|
"nodemailer": "10.1.1",
|
|
33
33
|
"@types/nodemailer": "8.0.2",
|
|
34
|
-
"@tatara/core": "0.1.
|
|
35
|
-
"@tatara/http": "0.1.
|
|
36
|
-
"@tatara/db": "0.
|
|
37
|
-
"@tatara/testing": "0.1.
|
|
38
|
-
"@tatara/auth": "0.1.
|
|
39
|
-
"@tatara/keycloak": "0.1.
|
|
40
|
-
"@tatara/authz": "0.
|
|
41
|
-
"@tatara/storage": "0.
|
|
42
|
-
"@tatara/email": "0.
|
|
34
|
+
"@tatara/core": "0.1.1",
|
|
35
|
+
"@tatara/http": "0.1.1",
|
|
36
|
+
"@tatara/db": "0.2.0",
|
|
37
|
+
"@tatara/testing": "0.1.1",
|
|
38
|
+
"@tatara/auth": "0.1.1",
|
|
39
|
+
"@tatara/keycloak": "0.1.1",
|
|
40
|
+
"@tatara/authz": "0.2.0",
|
|
41
|
+
"@tatara/storage": "0.2.0",
|
|
42
|
+
"@tatara/email": "0.2.0",
|
|
43
43
|
"@tatara/i18n": "0.1.0",
|
|
44
|
-
"@tatara/jobs": "0.
|
|
44
|
+
"@tatara/jobs": "0.2.0"
|
|
45
45
|
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
|
|
3
|
-
import { authz } from "#core/authz/index";
|
|
4
|
-
import { defineCommand } from "@tatara/core";
|
|
5
|
-
|
|
6
|
-
export default defineCommand({
|
|
7
|
-
name: "authz:grant-role",
|
|
8
|
-
description: "Assign a role (built-in key or custom role key) to a user in an org",
|
|
9
|
-
args: z.object({
|
|
10
|
-
user: z.string().min(1),
|
|
11
|
-
role: z.string().min(1),
|
|
12
|
-
org: z.string().default(DEFAULT_ORG_ID),
|
|
13
|
-
}),
|
|
14
|
-
async run({ user, role, org }, { logger }) {
|
|
15
|
-
const found = (await authz.store.findRoleByKey(org, role)) ??
|
|
16
|
-
(await authz.store.findRoleByKey(SYSTEM_ORG_ID, role));
|
|
17
|
-
if (!found) throw new Error(`No role "${role}" (run authz:sync-permissions first for built-in roles)`);
|
|
18
|
-
const added = await authz.assignRole(null, org, user, found.id);
|
|
19
|
-
logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
|
|
20
|
-
},
|
|
21
|
-
});
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "@tatara/core";
|
|
2
|
-
import { authz } from "#core/authz/index";
|
|
3
|
-
|
|
4
|
-
export default defineCommand({
|
|
5
|
-
name: "authz:sync-permissions",
|
|
6
|
-
description: "Create/update built-in roles so their grants match the code (run on every deploy)",
|
|
7
|
-
async run(_args, { logger }) {
|
|
8
|
-
const result = await authz.syncSystemRoles();
|
|
9
|
-
logger.info(result, "built-in roles in sync");
|
|
10
|
-
},
|
|
11
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { authz } from "#core/authz/index";
|
|
2
|
-
import { defineSeed } from "@tatara/db";
|
|
3
|
-
|
|
4
|
-
export default defineSeed({
|
|
5
|
-
name: "authz:system-roles",
|
|
6
|
-
order: -10,
|
|
7
|
-
async run({ logger }) {
|
|
8
|
-
logger.info(await authz.syncSystemRoles(), "built-in roles in sync");
|
|
9
|
-
},
|
|
10
|
-
});
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { createAuthz } from "@tatara/authz";
|
|
2
|
-
import { store } from "#core/authz/db/store";
|
|
3
|
-
import { SYSTEM_ROLES } from "#core/authz/system-roles";
|
|
4
|
-
import { catalog, type PermissionKey } from "#generated/permissions";
|
|
5
|
-
|
|
6
|
-
/** The app's authorization: its store, the permission catalog from `tatara sync`, built-in roles. */
|
|
7
|
-
export const authz = createAuthz<PermissionKey>({ store, catalog, systemRoles: SYSTEM_ROLES });
|
|
8
|
-
|
|
9
|
-
export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
|
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
|
2
|
-
import type { OverlayEffect, Scope } from "@tatara/authz";
|
|
3
|
-
import { SYSTEM_ORG_ID } from "@tatara/authz";
|
|
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 "@tatara/authz";
|
|
12
|
-
import { db } from "#core/db/client";
|
|
13
|
-
import { newId } from "@tatara/db";
|
|
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
|
-
};
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import type { OverlayEffect, Scope } from "@tatara/authz";
|
|
2
|
-
import { SYSTEM_ORG_ID } from "@tatara/authz";
|
|
3
|
-
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
|
|
4
|
-
import { db } from "#core/db/client";
|
|
5
|
-
import { newId } from "@tatara/db";
|
|
6
|
-
|
|
7
|
-
const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
|
|
8
|
-
|
|
9
|
-
interface OverlayRecord {
|
|
10
|
-
id: string;
|
|
11
|
-
orgId: string;
|
|
12
|
-
userId: string;
|
|
13
|
-
resource: string;
|
|
14
|
-
action: string;
|
|
15
|
-
scope: string;
|
|
16
|
-
effect: string;
|
|
17
|
-
grantedBy: string | null;
|
|
18
|
-
reason: string | null;
|
|
19
|
-
expiresAt: Date | null;
|
|
20
|
-
createdAt: Date;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function toOverlay(row: OverlayRecord): OverlayRow {
|
|
24
|
-
return { ...row, scope: toScope(row.scope), effect: row.effect as OverlayEffect };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const roleInclude = (orgId: string) => ({
|
|
28
|
-
permissions: true,
|
|
29
|
-
_count: { select: { members: { where: { orgId } } } },
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
interface RoleRecord {
|
|
33
|
-
id: string;
|
|
34
|
-
orgId: string;
|
|
35
|
-
key: string;
|
|
36
|
-
name: string;
|
|
37
|
-
description: string | null;
|
|
38
|
-
isSystem: boolean;
|
|
39
|
-
permissions: { resource: string; action: string; scope: string }[];
|
|
40
|
-
_count: { members: number };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toRole(role: RoleRecord): RoleRow {
|
|
44
|
-
return {
|
|
45
|
-
id: role.id,
|
|
46
|
-
orgId: role.orgId,
|
|
47
|
-
key: role.key,
|
|
48
|
-
name: role.name,
|
|
49
|
-
description: role.description,
|
|
50
|
-
isSystem: role.isSystem,
|
|
51
|
-
grants: role.permissions.map((g) => ({
|
|
52
|
-
resource: g.resource,
|
|
53
|
-
action: g.action,
|
|
54
|
-
scope: toScope(g.scope),
|
|
55
|
-
})),
|
|
56
|
-
memberCount: role._count.members,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const grantRows = (grants: PermissionGrant[]) =>
|
|
61
|
-
grants.map((g) => ({ id: newId(), resource: g.resource, action: g.action, scope: g.scope ?? "" }));
|
|
62
|
-
|
|
63
|
-
export const store: AuthzStore = {
|
|
64
|
-
async roleGrants(orgId, userId, impliedRoleKeys) {
|
|
65
|
-
const roles = await db.authzRole.findMany({
|
|
66
|
-
where: {
|
|
67
|
-
OR: [
|
|
68
|
-
{ members: { some: { orgId, userId } } },
|
|
69
|
-
...(impliedRoleKeys.length
|
|
70
|
-
? [{ orgId: SYSTEM_ORG_ID, key: { in: impliedRoleKeys } }]
|
|
71
|
-
: []),
|
|
72
|
-
],
|
|
73
|
-
},
|
|
74
|
-
include: { permissions: true },
|
|
75
|
-
});
|
|
76
|
-
return {
|
|
77
|
-
roles: roles.map((r) => r.key),
|
|
78
|
-
grants: roles.flatMap((r) =>
|
|
79
|
-
r.permissions.map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
|
|
80
|
-
),
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
async activeOverlays(orgId, userId, now) {
|
|
85
|
-
const rows = await db.authzOverlay.findMany({
|
|
86
|
-
where: { orgId, userId, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
|
87
|
-
});
|
|
88
|
-
return rows.map(toOverlay);
|
|
89
|
-
},
|
|
90
|
-
|
|
91
|
-
async listRoles(orgId) {
|
|
92
|
-
const roles = await db.authzRole.findMany({
|
|
93
|
-
where: { orgId: { in: [SYSTEM_ORG_ID, orgId] } },
|
|
94
|
-
include: roleInclude(orgId),
|
|
95
|
-
orderBy: { key: "asc" },
|
|
96
|
-
});
|
|
97
|
-
return roles.map(toRole);
|
|
98
|
-
},
|
|
99
|
-
|
|
100
|
-
async findRole(orgId, roleId) {
|
|
101
|
-
const role = await db.authzRole.findFirst({
|
|
102
|
-
where: { id: roleId, orgId: { in: [SYSTEM_ORG_ID, orgId] } },
|
|
103
|
-
include: roleInclude(orgId),
|
|
104
|
-
});
|
|
105
|
-
return role ? toRole(role) : null;
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
async findRoleByKey(orgId, key) {
|
|
109
|
-
const role = await db.authzRole.findFirst({ where: { key, orgId }, include: roleInclude(orgId) });
|
|
110
|
-
return role ? toRole(role) : null;
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
async createRole(input) {
|
|
114
|
-
const id = newId();
|
|
115
|
-
await db.authzRole.create({
|
|
116
|
-
data: {
|
|
117
|
-
id,
|
|
118
|
-
orgId: input.orgId,
|
|
119
|
-
key: input.key,
|
|
120
|
-
name: input.name,
|
|
121
|
-
description: input.description,
|
|
122
|
-
isSystem: input.isSystem,
|
|
123
|
-
permissions: { create: grantRows(input.grants) },
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
return id;
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
async updateRole(roleId, input) {
|
|
130
|
-
await db.$transaction(async (tx) => {
|
|
131
|
-
await tx.authzRole.update({
|
|
132
|
-
where: { id: roleId },
|
|
133
|
-
data: {
|
|
134
|
-
...(input.name === undefined ? {} : { name: input.name }),
|
|
135
|
-
...(input.description === undefined ? {} : { description: input.description }),
|
|
136
|
-
},
|
|
137
|
-
});
|
|
138
|
-
if (input.grants) {
|
|
139
|
-
await tx.authzRolePermission.deleteMany({ where: { roleId } });
|
|
140
|
-
await tx.authzRolePermission.createMany({
|
|
141
|
-
data: grantRows(input.grants).map((g) => ({ ...g, roleId })),
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
async deleteRole(roleId) {
|
|
148
|
-
await db.authzRole.delete({ where: { id: roleId } });
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
async memberRoles(orgId, userId) {
|
|
152
|
-
const roles = await db.authzRole.findMany({
|
|
153
|
-
where: { members: { some: { orgId, userId } } },
|
|
154
|
-
include: roleInclude(orgId),
|
|
155
|
-
});
|
|
156
|
-
return roles.map(toRole);
|
|
157
|
-
},
|
|
158
|
-
|
|
159
|
-
async assignRole(orgId, userId, roleId, by) {
|
|
160
|
-
const existing = await db.authzMemberRole.findFirst({ where: { orgId, userId, roleId } });
|
|
161
|
-
if (existing) return false;
|
|
162
|
-
await db.authzMemberRole.create({ data: { id: newId(), orgId, userId, roleId, assignedBy: by } });
|
|
163
|
-
return true;
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
async revokeRole(orgId, userId, roleId) {
|
|
167
|
-
const { count } = await db.authzMemberRole.deleteMany({ where: { orgId, userId, roleId } });
|
|
168
|
-
return count > 0;
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
async removeMember(orgId, userId) {
|
|
172
|
-
const [roles, overlays] = await db.$transaction([
|
|
173
|
-
db.authzMemberRole.deleteMany({ where: { orgId, userId } }),
|
|
174
|
-
db.authzOverlay.deleteMany({ where: { orgId, userId } }),
|
|
175
|
-
]);
|
|
176
|
-
return { roles: roles.count, overlays: overlays.count };
|
|
177
|
-
},
|
|
178
|
-
async listOverlays(orgId, userId) {
|
|
179
|
-
const rows = await db.authzOverlay.findMany({
|
|
180
|
-
where: { orgId, userId },
|
|
181
|
-
orderBy: { createdAt: "desc" },
|
|
182
|
-
});
|
|
183
|
-
return rows.map(toOverlay);
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
async addOverlay(input) {
|
|
187
|
-
const id = newId();
|
|
188
|
-
const scope = input.scope ?? "";
|
|
189
|
-
await db.authzOverlay.deleteMany({
|
|
190
|
-
where: {
|
|
191
|
-
orgId: input.orgId,
|
|
192
|
-
userId: input.userId,
|
|
193
|
-
resource: input.resource,
|
|
194
|
-
action: input.action,
|
|
195
|
-
scope,
|
|
196
|
-
effect: input.effect,
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
await db.authzOverlay.create({ data: { ...input, id, scope } });
|
|
200
|
-
return id;
|
|
201
|
-
},
|
|
202
|
-
|
|
203
|
-
async removeOverlay(orgId, userId, overlayId) {
|
|
204
|
-
const row = await db.authzOverlay.findFirst({ where: { id: overlayId, orgId, userId } });
|
|
205
|
-
if (!row) return null;
|
|
206
|
-
await db.authzOverlay.deleteMany({ where: { id: overlayId, orgId, userId } });
|
|
207
|
-
return toOverlay(row);
|
|
208
|
-
},
|
|
209
|
-
|
|
210
|
-
async writeAudit(input) {
|
|
211
|
-
await db.authzAuditLog.create({
|
|
212
|
-
data: {
|
|
213
|
-
id: newId(),
|
|
214
|
-
orgId: input.orgId,
|
|
215
|
-
actorId: input.actorId,
|
|
216
|
-
targetUserId: input.targetUserId,
|
|
217
|
-
changeType: input.changeType,
|
|
218
|
-
before: input.before === undefined ? null : JSON.stringify(input.before),
|
|
219
|
-
after: input.after === undefined ? null : JSON.stringify(input.after),
|
|
220
|
-
},
|
|
221
|
-
});
|
|
222
|
-
},
|
|
223
|
-
|
|
224
|
-
async listAudit(orgId, limit) {
|
|
225
|
-
const rows = await db.authzAuditLog.findMany({
|
|
226
|
-
where: { orgId },
|
|
227
|
-
orderBy: { id: "desc" },
|
|
228
|
-
take: limit,
|
|
229
|
-
});
|
|
230
|
-
return rows.map((r) => ({
|
|
231
|
-
...r,
|
|
232
|
-
before: r.before ? (JSON.parse(r.before) as unknown) : null,
|
|
233
|
-
after: r.after ? (JSON.parse(r.after) as unknown) : null,
|
|
234
|
-
}));
|
|
235
|
-
},
|
|
236
|
-
};
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "@tatara/core";
|
|
2
|
-
import { env } from "#core/config/env";
|
|
3
|
-
import { migrateDatabase } from "#core/db/migrate";
|
|
4
|
-
|
|
5
|
-
export default defineCommand({
|
|
6
|
-
name: "db:migrate",
|
|
7
|
-
description: "Apply pending database migrations",
|
|
8
|
-
async run(_args, { logger }) {
|
|
9
|
-
await migrateDatabase(env.DATABASE_URL);
|
|
10
|
-
logger.info("migrations applied");
|
|
11
|
-
},
|
|
12
|
-
});
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { defineCommand, flag } from "@tatara/core";
|
|
3
|
-
import { env } from "#core/config/env";
|
|
4
|
-
import { dropAllTables } from "#core/db/admin";
|
|
5
|
-
import { migrateDatabase } from "#core/db/migrate";
|
|
6
|
-
|
|
7
|
-
export default defineCommand({
|
|
8
|
-
name: "db:reset",
|
|
9
|
-
description: "Drop every table and re-apply migrations (refuses to run in production)",
|
|
10
|
-
args: z.object({ force: flag() }),
|
|
11
|
-
async run({ force }, { logger }) {
|
|
12
|
-
if (env.NODE_ENV === "production" && !force) {
|
|
13
|
-
throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
|
|
14
|
-
}
|
|
15
|
-
await dropAllTables(env.DATABASE_URL);
|
|
16
|
-
await migrateDatabase(env.DATABASE_URL);
|
|
17
|
-
logger.info("database reset");
|
|
18
|
-
},
|
|
19
|
-
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "@tatara/core";
|
|
2
|
-
import { seeds } from "#generated/seeds";
|
|
3
|
-
|
|
4
|
-
export default defineCommand({
|
|
5
|
-
name: "db:seed",
|
|
6
|
-
description: "Run every idempotent seed (core first, then modules)",
|
|
7
|
-
async run(_args, { logger }) {
|
|
8
|
-
for (const seed of [...seeds].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))) {
|
|
9
|
-
await seed.run({ logger: logger.child({ seed: seed.name }) });
|
|
10
|
-
logger.info({ seed: seed.name }, "seeded");
|
|
11
|
-
}
|
|
12
|
-
},
|
|
13
|
-
});
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { expect, it } from "vitest";
|
|
2
|
-
import { parseFlags } from "@tatara/core";
|
|
3
|
-
import reset from "#core/db/commands/reset";
|
|
4
|
-
|
|
5
|
-
it("does not treat --force false as forcing db:reset", () => {
|
|
6
|
-
expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
|
|
7
|
-
});
|