@tatara/cli 0.12.0 → 0.13.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.
package/dist/bin.mjs
CHANGED
|
@@ -84,6 +84,18 @@ const authz = {
|
|
|
84
84
|
label: "Permission system",
|
|
85
85
|
dependencies: ["@tatara/authz"],
|
|
86
86
|
slots: [
|
|
87
|
+
{
|
|
88
|
+
file: "src/core/authz/system-roles.ts",
|
|
89
|
+
slot: "admin",
|
|
90
|
+
unless: ["auth-keycloak"],
|
|
91
|
+
content: "{ key: \"admin\", name: \"Admin\", description: \"Full access, including roles and members.\" },"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
file: "src/core/authz/system-roles.ts",
|
|
95
|
+
slot: "admin",
|
|
96
|
+
when: ["auth-keycloak"],
|
|
97
|
+
content: "{ key: \"admin\", name: \"Admin\", description: \"Full access, including roles and members.\", heldBy: \"identity\" },"
|
|
98
|
+
},
|
|
87
99
|
{
|
|
88
100
|
file: "src/core/authz/index.ts",
|
|
89
101
|
slot: "options",
|
|
@@ -136,8 +148,11 @@ const authz = {
|
|
|
136
148
|
- Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/index\`
|
|
137
149
|
and declare \`security: [{ bearerAuth: ["project:read"] }]\`. For scoped actions, call
|
|
138
150
|
\`resolveScope(principal, "doc:read")\` in the service and filter rows by the returned scope.
|
|
139
|
-
- Effective permissions =
|
|
140
|
-
|
|
151
|
+
- Effective permissions = grants of the caller's roles + grant overlays − deny overlays. Deny
|
|
152
|
+
always wins. Roles are assigned in authz (the org's creator gets \`admin\`); a built-in role with
|
|
153
|
+
\`heldBy: "members"\` goes to every member, \`heldBy: "identity"\` to holders of that identity role.
|
|
154
|
+
- better-auth's member role is membership only (\`owner\` or \`member\`): \`authzPlugin\` in
|
|
155
|
+
\`src/modules/users/services/auth.ts\` refuses others and gives the creator \`admin\`.
|
|
141
156
|
- Custom roles and grant overlays can never grant \`role\`, \`member\` or \`audit_log\`.
|
|
142
157
|
- Built-in roles live in \`src/core/authz/system-roles.ts\`. \`authz:sync-permissions\` brings the database
|
|
143
158
|
in line with them; \`deploy\` runs it every deploy, \`db:seed\` locally.
|
|
@@ -476,7 +491,7 @@ const admin = {
|
|
|
476
491
|
{
|
|
477
492
|
file: "src/core/authz/system-roles.ts",
|
|
478
493
|
slot: "roles",
|
|
479
|
-
content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\" },"
|
|
494
|
+
content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\", heldBy: \"identity\" },"
|
|
480
495
|
},
|
|
481
496
|
{
|
|
482
497
|
file: "src/modules/staff/services/auth.ts",
|
|
@@ -516,22 +531,6 @@ const admin = {
|
|
|
516
531
|
}
|
|
517
532
|
]
|
|
518
533
|
};
|
|
519
|
-
const MEMBERS = `/** The org's members, a page at a time. */
|
|
520
|
-
async function* members(orgId: string) {
|
|
521
|
-
const { adapter } = await auth.$context;
|
|
522
|
-
for (let offset = 0; ; offset += 100) {
|
|
523
|
-
const page = await adapter.findMany<{ userId: string; role: string }>({
|
|
524
|
-
model: "member",
|
|
525
|
-
where: [{ field: "organizationId", value: orgId }],
|
|
526
|
-
limit: 100,
|
|
527
|
-
offset,
|
|
528
|
-
sortBy: { field: "id", direction: "asc" },
|
|
529
|
-
});
|
|
530
|
-
yield* page;
|
|
531
|
-
if (page.length < 100) return;
|
|
532
|
-
}
|
|
533
|
-
}`;
|
|
534
|
-
const hasRole = "m.role.split(\",\").map((r) => r.trim()).includes(role)";
|
|
535
534
|
const notifications = {
|
|
536
535
|
id: "notifications",
|
|
537
536
|
kind: "addon",
|
|
@@ -606,7 +605,21 @@ const notifications = {
|
|
|
606
605
|
file: "src/modules/notifications/services/notifier.ts",
|
|
607
606
|
slot: "helpers",
|
|
608
607
|
when: ["auth-orgs"],
|
|
609
|
-
content:
|
|
608
|
+
content: `/** The org's members, a page at a time. */
|
|
609
|
+
async function* members(orgId: string) {
|
|
610
|
+
const { adapter } = await auth.$context;
|
|
611
|
+
for (let offset = 0; ; offset += 100) {
|
|
612
|
+
const page = await adapter.findMany<{ userId: string; role: string }>({
|
|
613
|
+
model: "member",
|
|
614
|
+
where: [{ field: "organizationId", value: orgId }],
|
|
615
|
+
limit: 100,
|
|
616
|
+
offset,
|
|
617
|
+
sortBy: { field: "id", direction: "asc" },
|
|
618
|
+
});
|
|
619
|
+
yield* page;
|
|
620
|
+
if (page.length < 100) return;
|
|
621
|
+
}
|
|
622
|
+
}`
|
|
610
623
|
},
|
|
611
624
|
{
|
|
612
625
|
file: "src/modules/notifications/services/notifier.ts",
|
|
@@ -616,7 +629,7 @@ const notifications = {
|
|
|
616
629
|
content: [
|
|
617
630
|
"recipients: {",
|
|
618
631
|
" async *role(orgId, role) {",
|
|
619
|
-
`\t\tfor await (const m of members(orgId)) if (
|
|
632
|
+
`\t\tfor await (const m of members(orgId)) if (m.role.split(",").map((r) => r.trim()).includes(role)) yield m.userId;`,
|
|
620
633
|
" },",
|
|
621
634
|
" permission() {",
|
|
622
635
|
" throw new Error(\"Notifying by permission needs the authz add-on\");",
|
|
@@ -630,8 +643,12 @@ const notifications = {
|
|
|
630
643
|
when: ["auth-orgs", "authz"],
|
|
631
644
|
content: [
|
|
632
645
|
"recipients: {",
|
|
646
|
+
" // authz roles (assigned, or held by every member): better-auth's member role is only owner or member",
|
|
633
647
|
" async *role(orgId, role) {",
|
|
634
|
-
|
|
648
|
+
" for await (const m of members(orgId)) {",
|
|
649
|
+
" const who = { userId: m.userId, orgId, orgRole: m.role, roles: [], kind: \"user\" as const, claims: {} };",
|
|
650
|
+
" if ((await authz.effectivePermissions(who)).roles.includes(role)) yield m.userId;",
|
|
651
|
+
" }",
|
|
635
652
|
" },",
|
|
636
653
|
" // the same check a request makes: built-in roles, assigned roles and overlays",
|
|
637
654
|
" async *permission(orgId, permission) {",
|
|
@@ -647,7 +664,7 @@ const notifications = {
|
|
|
647
664
|
file: "src/modules/notifications/services/notifier.ts",
|
|
648
665
|
slot: "imports",
|
|
649
666
|
when: ["auth-orgs", "authz"],
|
|
650
|
-
content: "import { checkPermission } from \"#core/authz/index\";\nimport type { PermissionKey } from \"#generated/permissions\";"
|
|
667
|
+
content: "import { authz, checkPermission } from \"#core/authz/index\";\nimport type { PermissionKey } from \"#generated/permissions\";"
|
|
651
668
|
},
|
|
652
669
|
{
|
|
653
670
|
file: "src/modules/notifications/services/notifier.ts",
|
|
@@ -1546,16 +1563,15 @@ const authOrgs = {
|
|
|
1546
1563
|
slot: "plugins",
|
|
1547
1564
|
when: ["authz"],
|
|
1548
1565
|
content: [
|
|
1549
|
-
"organization(
|
|
1550
|
-
"
|
|
1551
|
-
"
|
|
1552
|
-
"
|
|
1553
|
-
"
|
|
1554
|
-
"
|
|
1555
|
-
"
|
|
1556
|
-
"
|
|
1557
|
-
"
|
|
1558
|
-
" },",
|
|
1566
|
+
"organization(),",
|
|
1567
|
+
"// better-auth keeps who is in which org (owner or member); authz decides what they may do.",
|
|
1568
|
+
"// The creator gets admin (`creatorRole`). better-auth shows these lists, emails included,",
|
|
1569
|
+
"// to any member: `endpoints` puts them behind an authz permission",
|
|
1570
|
+
"authzPlugin(authz, {",
|
|
1571
|
+
" endpoints: {",
|
|
1572
|
+
" \"/organization/list-members\": \"member:read\",",
|
|
1573
|
+
" \"/organization/get-full-organization\": \"member:read\",",
|
|
1574
|
+
" \"/organization/list-invitations\": \"member:read\",",
|
|
1559
1575
|
" },",
|
|
1560
1576
|
"}),",
|
|
1561
1577
|
"admin(),"
|
|
@@ -1566,7 +1582,7 @@ const authOrgs = {
|
|
|
1566
1582
|
file: "src/modules/users/services/auth.ts",
|
|
1567
1583
|
slot: "imports",
|
|
1568
1584
|
when: ["authz"],
|
|
1569
|
-
content: "import { authz } from \"#core/authz/index\";"
|
|
1585
|
+
content: "import { authzPlugin } from \"@tatara/authz/better-auth\";\nimport { authz } from \"#core/authz/index\";"
|
|
1570
1586
|
},
|
|
1571
1587
|
{
|
|
1572
1588
|
file: "tests/helpers/auth.ts",
|
|
@@ -3484,6 +3500,41 @@ const authzPackage = {
|
|
|
3484
3500
|
}
|
|
3485
3501
|
};
|
|
3486
3502
|
//#endregion
|
|
3503
|
+
//#region src/upgrade/codemods/authz-roles.ts
|
|
3504
|
+
const PATH = "src/core/authz/system-roles.ts";
|
|
3505
|
+
/** Adds `heldBy` to the role literal `{ key: "<key>", … }`, unless it has one. */
|
|
3506
|
+
function holdRole(text, key, heldBy) {
|
|
3507
|
+
const start = text.search(new RegExp(`\\{\\s*key:\\s*"${key}"`));
|
|
3508
|
+
if (start === -1) return text;
|
|
3509
|
+
const end = text.indexOf("}", start);
|
|
3510
|
+
if (end === -1) return text;
|
|
3511
|
+
const literal = text.slice(start, end);
|
|
3512
|
+
if (literal.includes("heldBy")) return text;
|
|
3513
|
+
const body = literal.replace(/,?\s*$/, "");
|
|
3514
|
+
return `${text.slice(0, start)}${body}, heldBy: "${heldBy}" ${text.slice(end)}`;
|
|
3515
|
+
}
|
|
3516
|
+
/** Roles only follow better-auth's member or user role where the app says so (`heldBy`). */
|
|
3517
|
+
const authzRoles = {
|
|
3518
|
+
version: "0.13.0",
|
|
3519
|
+
id: "authz-roles",
|
|
3520
|
+
description: "Built-in roles say who holds them (heldBy); better-auth's member role is membership only",
|
|
3521
|
+
async run(ctx) {
|
|
3522
|
+
const { config } = ctx.project;
|
|
3523
|
+
if (!config.addons.includes("authz")) return;
|
|
3524
|
+
const text = await ctx.read(PATH).catch(() => void 0);
|
|
3525
|
+
if (text !== void 0) {
|
|
3526
|
+
let next = holdRole(text, "member", "members");
|
|
3527
|
+
next = holdRole(next, "staff", "identity");
|
|
3528
|
+
if (config.authMethods.includes("keycloak")) {
|
|
3529
|
+
for (const [, key] of text.matchAll(/\{\s*key:\s*"([^"]+)"/g)) if (key && key !== "member") next = holdRole(next, key, "identity");
|
|
3530
|
+
}
|
|
3531
|
+
if (next !== text) await ctx.write(PATH, next);
|
|
3532
|
+
}
|
|
3533
|
+
ctx.report.notes.push("Built-in roles no longer follow better-auth's member role (or the user role, unless the role has heldBy: \"identity\"). Give access with authz roles (grantSystemRole, assignRole); `member` is held by every member (heldBy: \"members\").");
|
|
3534
|
+
if (config.authMethods.includes("orgs")) ctx.report.notes.push("In src/modules/users/services/auth.ts, replace the organizationHooks that grant the creator's role and remove member access with authzPlugin(authz, { endpoints: { \"/organization/list-members\": \"member:read\", \"/organization/get-full-organization\": \"member:read\", \"/organization/list-invitations\": \"member:read\" } }) from @tatara/authz/better-auth, after organization(). It also keeps member roles to owner and member.");
|
|
3535
|
+
}
|
|
3536
|
+
};
|
|
3537
|
+
//#endregion
|
|
3487
3538
|
//#region src/upgrade/codemods/authz-stores.ts
|
|
3488
3539
|
const group = {
|
|
3489
3540
|
name: "authz store",
|
|
@@ -3934,6 +3985,30 @@ const jobsDrivers = {
|
|
|
3934
3985
|
}
|
|
3935
3986
|
};
|
|
3936
3987
|
//#endregion
|
|
3988
|
+
//#region src/upgrade/codemods/notifier-roles.ts
|
|
3989
|
+
const NOTIFIER = "src/modules/notifications/services/notifier.ts";
|
|
3990
|
+
const TEST = "src/modules/notifications/tests/role-recipients.test.ts";
|
|
3991
|
+
/** `recipients.role` reads authz roles: better-auth's member role is only owner or member. */
|
|
3992
|
+
const notifierRoles = {
|
|
3993
|
+
version: "0.13.1",
|
|
3994
|
+
id: "notifier-roles",
|
|
3995
|
+
description: "Notifying by role reaches holders of the authz role",
|
|
3996
|
+
async run(ctx) {
|
|
3997
|
+
const { config } = ctx.project;
|
|
3998
|
+
const addons = config.addons;
|
|
3999
|
+
if (!addons.includes("notifications") || !addons.includes("authz")) return;
|
|
4000
|
+
if (!config.authMethods.includes("orgs")) return;
|
|
4001
|
+
const text = await ctx.read(NOTIFIER).catch(() => void 0);
|
|
4002
|
+
if (text === void 0) return;
|
|
4003
|
+
if (await ctx.baseline.isUnchanged(NOTIFIER, text)) await ctx.regenerate(NOTIFIER);
|
|
4004
|
+
else ctx.report.notes.push(`In ${NOTIFIER}, recipients.role should check authz roles: \`(await authz.effectivePermissions(who)).roles.includes(role)\` for each member, with who as in recipients.permission. Matching member.role finds nobody now that it is only owner or member.`);
|
|
4005
|
+
if (await ctx.read(TEST).catch(() => void 0) === void 0) {
|
|
4006
|
+
const test = await ctx.renderCurrent(TEST);
|
|
4007
|
+
if (test !== void 0) await ctx.write(TEST, test);
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
};
|
|
4011
|
+
//#endregion
|
|
3937
4012
|
//#region src/upgrade/codemods/package-commands.ts
|
|
3938
4013
|
/** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
|
|
3939
4014
|
const COPIES = [
|
|
@@ -3985,7 +4060,9 @@ const CODEMODS = [
|
|
|
3985
4060
|
httpLimits,
|
|
3986
4061
|
jobsDrivers,
|
|
3987
4062
|
emailV2,
|
|
3988
|
-
deployLedger
|
|
4063
|
+
deployLedger,
|
|
4064
|
+
authzRoles,
|
|
4065
|
+
notifierRoles
|
|
3989
4066
|
];
|
|
3990
4067
|
//#endregion
|
|
3991
4068
|
//#region src/upgrade/context.ts
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Built-in roles. Add a key here, then grant it permissions in module permission files. A role is
|
|
3
|
+
* held only by those it's assigned to, unless `heldBy` says every org member or an identity role.
|
|
4
|
+
*/
|
|
2
5
|
export const SYSTEM_ROLES = [
|
|
3
|
-
|
|
4
|
-
{ key: "member", name: "Member", description: "
|
|
6
|
+
// tatara:slot admin
|
|
7
|
+
{ key: "member", name: "Member", description: "Every member of the org.", heldBy: "members" },
|
|
5
8
|
// tatara:slot roles
|
|
6
9
|
] as const;
|
|
7
10
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { InlineDriver } from "@tatara/jobs";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { jobs } from "#core/jobs/index";
|
|
4
|
+
import { notify } from "#modules/notifications/services/notifier";
|
|
5
|
+
import { api } from "#tests/helpers/api";
|
|
6
|
+
import { authenticate } from "#tests/helpers/auth";
|
|
7
|
+
import { applyTestAccess } from "#tests/helpers/authz";
|
|
8
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
9
|
+
|
|
10
|
+
const drain = () => (jobs.driver as InlineDriver).drain();
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
await resetDatabase();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("notifying by role", () => {
|
|
17
|
+
it("reaches the members who hold the authz role, not better-auth's member role", async () => {
|
|
18
|
+
const admin = await authenticate();
|
|
19
|
+
await applyTestAccess(admin.userId, { systemRoles: ["admin"], orgId: admin.orgId });
|
|
20
|
+
const plain = await authenticate();
|
|
21
|
+
await notify({
|
|
22
|
+
orgId: admin.orgId as string,
|
|
23
|
+
to: { role: "admin" },
|
|
24
|
+
notification: "example.welcome",
|
|
25
|
+
data: { name: "Ada" },
|
|
26
|
+
});
|
|
27
|
+
await drain();
|
|
28
|
+
const count = async (headers: Record<string, string>) =>
|
|
29
|
+
(await api.get<{ data: { count: number } }>("/api/notifications/unread-count", { headers }))
|
|
30
|
+
.body.data.count;
|
|
31
|
+
expect(await count(admin.headers)).toBe(1);
|
|
32
|
+
expect(await count(plain.headers)).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/templates/versions.json
CHANGED
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"@tatara/http": "0.1.2",
|
|
36
36
|
"@tatara/db": "0.2.2",
|
|
37
37
|
"@tatara/testing": "0.1.1",
|
|
38
|
-
"@tatara/auth": "0.2.
|
|
38
|
+
"@tatara/auth": "0.2.1",
|
|
39
39
|
"@tatara/keycloak": "0.1.2",
|
|
40
|
-
"@tatara/authz": "0.
|
|
40
|
+
"@tatara/authz": "0.5.0",
|
|
41
41
|
"@tatara/storage": "0.2.0",
|
|
42
42
|
"@tatara/email": "0.3.0",
|
|
43
43
|
"@tatara/i18n": "0.1.0",
|