@tatara/cli 0.1.0 → 0.1.2
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 +323 -131
- package/package.json +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +154 -0
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +109 -0
- package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +36 -0
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +23 -0
- package/templates/features/authz/files/src/core/authz/constants.ts +4 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +5 -5
- package/templates/features/authz/files/src/core/authz/services/members.ts +17 -1
- package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
- package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
- package/templates/features/authz/files/src/core/authz/types.ts +2 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +13 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +7 -0
- package/templates/features/base/files/AGENTS.md +2 -0
- package/templates/features/base/files/Dockerfile +1 -0
- package/templates/features/base/files/_github/workflows/ci.yml +10 -0
- package/templates/features/base/files/src/core/commands/define.ts +9 -1
- package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
- package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
- package/templates/features/base/files/src/core/http/app.ts +6 -1
- package/templates/features/base/files/src/core/http/errors.ts +40 -12
- package/templates/features/base/files/src/core/http/middleware/validation.ts +1 -1
- package/templates/features/base/files/src/core/http/response.ts +5 -0
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +19 -1
- package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +2 -0
- package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
- package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
- package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +2 -2
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +2 -2
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +3 -2
- package/templates/features/keycloak/files/src/core/auth/principal.ts +0 -27
- package/templates/features/keycloak/files/tests/helpers/auth.ts +0 -52
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +0 -18
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +0 -10
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +0 -10
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +0 -10
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +0 -13
- /package/templates/features/{keycloak → auth-keycloak}/files/keycloak/realm.json +0 -0
- /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/commands/keycloak-token.ts +0 -0
- /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/keycloak.ts +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { createAuthEndpoint } from "better-auth/api";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
type KeycloakClaims,
|
|
6
|
+
organizationOf,
|
|
7
|
+
rolesOf,
|
|
8
|
+
verifyAccessToken,
|
|
9
|
+
} from "#core/auth/keycloak";
|
|
10
|
+
import { logger } from "#core/logging/logger";
|
|
11
|
+
|
|
12
|
+
export interface KeycloakPrincipal {
|
|
13
|
+
userId: string;
|
|
14
|
+
orgId: string | null;
|
|
15
|
+
roles: string[];
|
|
16
|
+
kind: "user" | "service";
|
|
17
|
+
claims: KeycloakClaims;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KeycloakTokensOptions {
|
|
21
|
+
/** Record the token's organization as a better-auth organization + membership. */
|
|
22
|
+
organizations?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Adapter = Parameters<NonNullable<BetterAuthPlugin["init"]>>[0]["adapter"];
|
|
26
|
+
|
|
27
|
+
const ACCOUNT_PROVIDER = "keycloak";
|
|
28
|
+
|
|
29
|
+
async function ensureUser(adapter: Adapter, claims: KeycloakClaims): Promise<boolean> {
|
|
30
|
+
// an unverified email could claim someone else's address, so it is never stored
|
|
31
|
+
const verified = claims.email_verified === true && Boolean(claims.email);
|
|
32
|
+
const email = verified
|
|
33
|
+
? (claims.email as string).toLowerCase()
|
|
34
|
+
: `${claims.sub}@users.keycloak.invalid`;
|
|
35
|
+
const name = claims.name ?? claims.preferred_username ?? claims.sub;
|
|
36
|
+
const owner = await adapter.findOne<{ id: string }>({
|
|
37
|
+
model: "user",
|
|
38
|
+
where: [{ field: "email", value: email }],
|
|
39
|
+
});
|
|
40
|
+
const existing = await adapter.findOne<{ id: string }>({
|
|
41
|
+
model: "user",
|
|
42
|
+
where: [{ field: "id", value: claims.sub }],
|
|
43
|
+
});
|
|
44
|
+
if (existing) {
|
|
45
|
+
const emailFree = !owner || owner.id === claims.sub;
|
|
46
|
+
if (!emailFree) logger.warn({ sub: claims.sub }, "keycloak email belongs to another account");
|
|
47
|
+
await adapter.update({
|
|
48
|
+
model: "user",
|
|
49
|
+
where: [{ field: "id", value: claims.sub }],
|
|
50
|
+
update: {
|
|
51
|
+
name,
|
|
52
|
+
...(emailFree ? { email, emailVerified: verified } : {}),
|
|
53
|
+
updatedAt: new Date(),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (owner) {
|
|
59
|
+
// never attach a Keycloak identity to an account that signs in another way
|
|
60
|
+
logger.warn({ sub: claims.sub }, "keycloak token email belongs to another account");
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const now = new Date();
|
|
64
|
+
await adapter.create({
|
|
65
|
+
model: "user",
|
|
66
|
+
data: { id: claims.sub, email, emailVerified: verified, name, createdAt: now, updatedAt: now },
|
|
67
|
+
forceAllowId: true,
|
|
68
|
+
});
|
|
69
|
+
await adapter.create({
|
|
70
|
+
model: "account",
|
|
71
|
+
data: {
|
|
72
|
+
userId: claims.sub,
|
|
73
|
+
accountId: claims.sub,
|
|
74
|
+
providerId: ACCOUNT_PROVIDER,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureMembership(adapter: Adapter, orgId: string, userId: string): Promise<void> {
|
|
83
|
+
const org = await adapter.findOne({
|
|
84
|
+
model: "organization",
|
|
85
|
+
where: [{ field: "id", value: orgId }],
|
|
86
|
+
});
|
|
87
|
+
if (!org) {
|
|
88
|
+
await adapter.create({
|
|
89
|
+
model: "organization",
|
|
90
|
+
data: { id: orgId, name: orgId, slug: orgId, createdAt: new Date() },
|
|
91
|
+
forceAllowId: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const member = await adapter.findOne({
|
|
95
|
+
model: "member",
|
|
96
|
+
where: [
|
|
97
|
+
{ field: "organizationId", value: orgId },
|
|
98
|
+
{ field: "userId", value: userId },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
if (!member) {
|
|
102
|
+
await adapter.create({
|
|
103
|
+
model: "member",
|
|
104
|
+
data: { organizationId: orgId, userId, role: "member", createdAt: new Date() },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Lets the API accept access tokens issued by Keycloak (a resource server).
|
|
111
|
+
* Verified tokens become principals; their users and organizations are kept in better-auth's tables.
|
|
112
|
+
*/
|
|
113
|
+
export function keycloakTokens(options: KeycloakTokensOptions = {}) {
|
|
114
|
+
// subjects already synced by this process; tokens are re-verified on every request regardless
|
|
115
|
+
const synced = new Set<string>();
|
|
116
|
+
return {
|
|
117
|
+
id: "keycloak-tokens",
|
|
118
|
+
endpoints: {
|
|
119
|
+
// no path: callable only on the server as auth.api.verifyKeycloakToken
|
|
120
|
+
verifyKeycloakToken: createAuthEndpoint(
|
|
121
|
+
{ method: "POST", body: z.object({ token: z.string() }) },
|
|
122
|
+
async (ctx): Promise<KeycloakPrincipal | null> => {
|
|
123
|
+
let claims: KeycloakClaims;
|
|
124
|
+
try {
|
|
125
|
+
claims = await verifyAccessToken(ctx.body.token);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logger.debug({ err: error }, "rejected keycloak token");
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const isService = claims.preferred_username?.startsWith("service-account-") ?? false;
|
|
131
|
+
const orgId = organizationOf(claims);
|
|
132
|
+
if (!isService) {
|
|
133
|
+
const key = `${claims.sub}|${orgId ?? ""}|${claims.email ?? ""}`;
|
|
134
|
+
if (!synced.has(key)) {
|
|
135
|
+
const { adapter } = ctx.context;
|
|
136
|
+
if (!(await ensureUser(adapter, claims))) return null;
|
|
137
|
+
if (orgId && options.organizations)
|
|
138
|
+
await ensureMembership(adapter, orgId, claims.sub);
|
|
139
|
+
if (synced.size > 10_000) synced.clear();
|
|
140
|
+
synced.add(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
userId: claims.sub,
|
|
145
|
+
orgId,
|
|
146
|
+
roles: rolesOf(claims),
|
|
147
|
+
kind: isService ? "service" : "user",
|
|
148
|
+
claims,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
} satisfies BetterAuthPlugin;
|
|
154
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { generateKeyPair, SignJWT } from "jose";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { auth } from "#core/auth/auth";
|
|
4
|
+
import { issuer } from "#core/auth/keycloak";
|
|
5
|
+
import { env } from "#core/config/env";
|
|
6
|
+
import { newId } from "#core/db/id";
|
|
7
|
+
import { api } from "#tests/helpers/api";
|
|
8
|
+
import { createTestUser, keycloakToken } from "#tests/helpers/auth";
|
|
9
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
10
|
+
|
|
11
|
+
interface Me {
|
|
12
|
+
data: { userId: string; orgId: string | null; roles: string[]; kind: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
16
|
+
const me = (token: string) => api.get<Me>("/api/me", { headers: bearer(token) });
|
|
17
|
+
|
|
18
|
+
// read through better-auth's adapter so the tests work with any ORM
|
|
19
|
+
async function find<T>(model: string, where: Record<string, string>): Promise<T | null> {
|
|
20
|
+
const { adapter } = await auth.$context;
|
|
21
|
+
return adapter.findOne<T>({
|
|
22
|
+
model,
|
|
23
|
+
where: Object.entries(where).map(([field, value]) => ({ field, value })),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
type UserRow = { email: string; emailVerified: boolean };
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
await resetDatabase();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("Keycloak access tokens", () => {
|
|
33
|
+
it("authenticates a valid token as the token's subject", async () => {
|
|
34
|
+
const sub = newId();
|
|
35
|
+
const res = await me(await keycloakToken({ sub, roles: ["teacher"] }));
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
expect(res.body.data).toMatchObject({ userId: sub, kind: "user", orgId: null });
|
|
38
|
+
expect(res.body.data.roles).toContain("teacher");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("creates the local user and links a keycloak account on first use", async () => {
|
|
42
|
+
const sub = newId();
|
|
43
|
+
await me(await keycloakToken({ sub, email: "kc@example.com" }));
|
|
44
|
+
const user = await find<UserRow>("user", { id: sub });
|
|
45
|
+
expect(user?.email).toBe("kc@example.com");
|
|
46
|
+
const account = await find<{ accountId: string }>("account", {
|
|
47
|
+
userId: sub,
|
|
48
|
+
providerId: "keycloak",
|
|
49
|
+
});
|
|
50
|
+
expect(account?.accountId).toBe(sub);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each([
|
|
54
|
+
["an expired token", { expiresIn: Math.floor(Date.now() / 1000) - 60 }],
|
|
55
|
+
["a token from another issuer", { issuer: "https://evil.example.com/realms/app" }],
|
|
56
|
+
["a token for another audience", { audience: "someone-else" }],
|
|
57
|
+
])("rejects %s", async (_label, options) => {
|
|
58
|
+
expect((await me(await keycloakToken(options))).status).toBe(401);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects a token signed with an unknown key", async () => {
|
|
62
|
+
const { privateKey } = await generateKeyPair("RS256");
|
|
63
|
+
const forged = await new SignJWT({ roles: ["admin"] })
|
|
64
|
+
.setProtectedHeader({ alg: "RS256", kid: "test" })
|
|
65
|
+
.setSubject(newId())
|
|
66
|
+
.setIssuer(issuer)
|
|
67
|
+
.setAudience(env.KEYCLOAK_AUDIENCE)
|
|
68
|
+
.setExpirationTime("5m")
|
|
69
|
+
.sign(privateKey);
|
|
70
|
+
expect((await me(forged)).status).toBe(401);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a bearer value that is not a token", async () => {
|
|
74
|
+
expect((await me("not-a-token")).status).toBe(401);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("treats service-account tokens as services without a user row", async () => {
|
|
78
|
+
const sub = newId();
|
|
79
|
+
const res = await me(await keycloakToken({ sub, username: "service-account-billing" }));
|
|
80
|
+
expect(res.body.data).toMatchObject({ userId: sub, kind: "service" });
|
|
81
|
+
expect(await find<UserRow>("user", { id: sub })).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
it("rejects a token whose email belongs to another account", async () => {
|
|
86
|
+
await createTestUser({ email: "taken@example.com" });
|
|
87
|
+
const sub = newId();
|
|
88
|
+
expect((await me(await keycloakToken({ sub, email: "taken@example.com" }))).status).toBe(401);
|
|
89
|
+
expect(await find<UserRow>("user", { id: sub })).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
it("does not store an email Keycloak has not verified", async () => {
|
|
94
|
+
const sub = newId();
|
|
95
|
+
await me(await keycloakToken({ sub, email: "victim@example.com", emailVerified: false }));
|
|
96
|
+
const user = await find<UserRow>("user", { id: sub });
|
|
97
|
+
expect(user?.email).not.toBe("victim@example.com");
|
|
98
|
+
expect(user?.emailVerified).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps the old email when the token's new email belongs to another account", async () => {
|
|
102
|
+
await createTestUser({ email: "taken@example.com" });
|
|
103
|
+
const sub = newId();
|
|
104
|
+
await me(await keycloakToken({ sub, email: "first@example.com" }));
|
|
105
|
+
const res = await me(await keycloakToken({ sub, email: "taken@example.com" }));
|
|
106
|
+
expect(res.status).toBe(200);
|
|
107
|
+
expect((await find<UserRow>("user", { id: sub }))?.email).toBe("first@example.com");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { auth } from "#core/auth/auth";
|
|
3
|
+
import { newId } from "#core/db/id";
|
|
4
|
+
import { api } from "#tests/helpers/api";
|
|
5
|
+
import { keycloakToken } from "#tests/helpers/auth";
|
|
6
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
7
|
+
|
|
8
|
+
interface Me {
|
|
9
|
+
data: { orgId: string | null };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const me = (token: string) =>
|
|
13
|
+
api.get<Me>("/api/me", { headers: { authorization: `Bearer ${token}` } });
|
|
14
|
+
|
|
15
|
+
// read through better-auth's adapter so the tests work with any ORM
|
|
16
|
+
async function find<T>(model: string, where: Record<string, string>): Promise<T | null> {
|
|
17
|
+
const { adapter } = await auth.$context;
|
|
18
|
+
return adapter.findOne<T>({
|
|
19
|
+
model,
|
|
20
|
+
where: Object.entries(where).map(([field, value]) => ({ field, value })),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
await resetDatabase();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("Keycloak access tokens with organizations", () => {
|
|
29
|
+
it("takes the organization from the token and records the membership", async () => {
|
|
30
|
+
const sub = newId();
|
|
31
|
+
const res = await me(await keycloakToken({ sub, organization: ["riyadh-school"] }));
|
|
32
|
+
expect(res.body.data.orgId).toBe("riyadh-school");
|
|
33
|
+
expect(await find("organization", { id: "riyadh-school" })).not.toBeNull();
|
|
34
|
+
expect(await find("member", { organizationId: "riyadh-school", userId: sub })).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { syncSystemRoles } from "#core/authz/services/sync";
|
|
3
|
+
import { api } from "#tests/helpers/api";
|
|
4
|
+
import { keycloakToken } from "#tests/helpers/auth";
|
|
5
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
6
|
+
|
|
7
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await resetDatabase();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("Keycloak access tokens with authz", () => {
|
|
14
|
+
it("lets token roles match built-in authz roles", async () => {
|
|
15
|
+
await syncSystemRoles();
|
|
16
|
+
const admin = await keycloakToken({ roles: ["admin"] });
|
|
17
|
+
const user = await keycloakToken({ roles: ["user"] });
|
|
18
|
+
expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
|
|
19
|
+
200,
|
|
20
|
+
);
|
|
21
|
+
expect((await api.get("/api/authorization/roles", { headers: bearer(user) })).status).toBe(403);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -15,9 +15,13 @@ export const AUDIT_CHANGE_TYPES = [
|
|
|
15
15
|
"member_role_revoked",
|
|
16
16
|
"overlay_granted",
|
|
17
17
|
"overlay_revoked",
|
|
18
|
+
"member_removed",
|
|
18
19
|
] as const;
|
|
19
20
|
export type AuditChangeType = (typeof AUDIT_CHANGE_TYPES)[number];
|
|
20
21
|
|
|
22
|
+
/** Audit actor for changes made by the system rather than a user (e.g. org membership hooks). */
|
|
23
|
+
export const SYSTEM_ACTOR = "system";
|
|
24
|
+
|
|
21
25
|
/** Org used when the principal has none (single-tenant apps, users without an active org). */
|
|
22
26
|
export const DEFAULT_ORG_ID = "default";
|
|
23
27
|
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
userParams,
|
|
20
20
|
userRoleParams,
|
|
21
21
|
} from "#core/authz/validators";
|
|
22
|
-
import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
|
|
22
|
+
import { errorResponses, jsonBody, jsonContent, success, successSchema } from "#core/http/response";
|
|
23
23
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
24
24
|
import { catalog } from "#generated/permissions";
|
|
25
25
|
|
|
@@ -100,7 +100,7 @@ router.openapi(
|
|
|
100
100
|
summary: "Create a custom role",
|
|
101
101
|
middleware: [requirePermission("role:create")] as const,
|
|
102
102
|
security: [{ bearerAuth: ["role:create"] }],
|
|
103
|
-
request: { body:
|
|
103
|
+
request: { body: jsonBody(createRoleSchema, "The role") },
|
|
104
104
|
responses: {
|
|
105
105
|
201: jsonContent(successSchema(roleSchema), "Created"),
|
|
106
106
|
...errorResponses(400, 409),
|
|
@@ -122,7 +122,7 @@ router.openapi(
|
|
|
122
122
|
summary: "Update a custom role",
|
|
123
123
|
middleware: [requirePermission("role:update")] as const,
|
|
124
124
|
security: [{ bearerAuth: ["role:update"] }],
|
|
125
|
-
request: { params: idParams, body:
|
|
125
|
+
request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
|
|
126
126
|
responses: {
|
|
127
127
|
200: jsonContent(successSchema(roleSchema), "Updated"),
|
|
128
128
|
...errorResponses(400, 404),
|
|
@@ -179,7 +179,7 @@ router.openapi(
|
|
|
179
179
|
summary: "Assign a role to a member",
|
|
180
180
|
middleware: [requirePermission("member:update")] as const,
|
|
181
181
|
security: [{ bearerAuth: ["member:update"] }],
|
|
182
|
-
request: { params: userParams, body:
|
|
182
|
+
request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
|
|
183
183
|
responses: {
|
|
184
184
|
200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
|
|
185
185
|
...errorResponses(404),
|
|
@@ -225,7 +225,7 @@ router.openapi(
|
|
|
225
225
|
summary: "Grant or deny one permission to a member",
|
|
226
226
|
middleware: [requirePermission("member:update")] as const,
|
|
227
227
|
security: [{ bearerAuth: ["member:update"] }],
|
|
228
|
-
request: { params: userParams, body:
|
|
228
|
+
request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
|
|
229
229
|
responses: {
|
|
230
230
|
201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
|
|
231
231
|
...errorResponses(400),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { invalidateMember } from "#core/authz/cache";
|
|
2
|
-
import type
|
|
2
|
+
import { type OverlayEffect, type Scope, SYSTEM_ACTOR } from "#core/authz/constants";
|
|
3
3
|
import { store } from "#core/authz/db/store";
|
|
4
4
|
import { audit } from "#core/authz/services/audit";
|
|
5
5
|
import { validateGrants } from "#core/authz/services/grants";
|
|
@@ -31,6 +31,22 @@ export async function revokeRole(actorId: string, orgId: string, userId: string,
|
|
|
31
31
|
await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_revoked", before: { roleId } });
|
|
32
32
|
}
|
|
33
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
|
+
|
|
34
50
|
export interface OverlayInput {
|
|
35
51
|
resource: string;
|
|
36
52
|
action: string;
|
|
@@ -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
|
+
});
|
|
@@ -77,6 +77,8 @@ export interface AuthzStore {
|
|
|
77
77
|
memberRoles(orgId: string, userId: string): Promise<RoleRow[]>;
|
|
78
78
|
assignRole(orgId: string, userId: string, roleId: string, by: string | null): Promise<boolean>;
|
|
79
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 }>;
|
|
80
82
|
listOverlays(orgId: string, userId: string): Promise<OverlayRow[]>;
|
|
81
83
|
addOverlay(input: Omit<OverlayRow, "id" | "createdAt">): Promise<string>;
|
|
82
84
|
removeOverlay(orgId: string, userId: string, overlayId: string): Promise<OverlayRow | null>;
|
|
@@ -194,6 +194,19 @@ export const store: AuthzStore = {
|
|
|
194
194
|
return true;
|
|
195
195
|
},
|
|
196
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
|
+
|
|
197
210
|
async listOverlays(orgId, userId) {
|
|
198
211
|
const rows = await db
|
|
199
212
|
.select()
|
|
@@ -168,6 +168,13 @@ export const store: AuthzStore = {
|
|
|
168
168
|
return count > 0;
|
|
169
169
|
},
|
|
170
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
|
+
},
|
|
171
178
|
async listOverlays(orgId, userId) {
|
|
172
179
|
const rows = await db.authzOverlay.findMany({
|
|
173
180
|
where: { orgId, userId },
|
|
@@ -45,6 +45,8 @@ __PM_EXEC__ tatara routes # every route and what protects it
|
|
|
45
45
|
## HTTP
|
|
46
46
|
|
|
47
47
|
- Every route uses `createRoute` from `@hono/zod-openapi`, with request and response schemas.
|
|
48
|
+
Declare request bodies with `jsonBody(schema, description)` (required; a missing body is a 400),
|
|
49
|
+
responses with `jsonContent(...)`.
|
|
48
50
|
The spec is served at `/openapi.json`, the reference UI at `/docs`.
|
|
49
51
|
- Responses use the envelope: `success(data)`, `paginated(items, page, pageSize, total)`.
|
|
50
52
|
Errors are thrown as `AppError` subclasses (`NotFoundError`, `ConflictError`,
|
|
@@ -5,6 +5,9 @@ on:
|
|
|
5
5
|
branches: [main]
|
|
6
6
|
pull_request:
|
|
7
7
|
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
8
11
|
jobs:
|
|
9
12
|
verify:
|
|
10
13
|
runs-on: ubuntu-latest
|
|
@@ -14,3 +17,10 @@ jobs:
|
|
|
14
17
|
- run: cp .env.example .env
|
|
15
18
|
# tatara:slot ci-services
|
|
16
19
|
- run: __PM_RUN__ verify
|
|
20
|
+
|
|
21
|
+
image:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v5
|
|
25
|
+
# catch a broken Dockerfile on the PR, not at deploy
|
|
26
|
+
- run: docker build --tag app:ci .
|
|
@@ -9,7 +9,7 @@ export interface CommandDefinition<S extends z.ZodType = z.ZodType> {
|
|
|
9
9
|
/** `module:action`, e.g. "billing:recalculate" */
|
|
10
10
|
name: string;
|
|
11
11
|
description: string;
|
|
12
|
-
/** flags parsed from `--key value`; use z.coerce for numbers and booleans */
|
|
12
|
+
/** flags parsed from `--key value`; use z.coerce for numbers and `flag()` for booleans */
|
|
13
13
|
args?: S;
|
|
14
14
|
run(args: z.infer<S>, ctx: CommandContext): Promise<void> | void;
|
|
15
15
|
}
|
|
@@ -23,3 +23,11 @@ export function defineCommand<S extends z.ZodType = typeof noArgs>(
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export const noArgs = z.object({});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A boolean flag: bare `--force` is true, `--force false|0|no|off` is false.
|
|
29
|
+
* (`z.coerce.boolean()` would read the string "false" as true.)
|
|
30
|
+
*/
|
|
31
|
+
export function flag() {
|
|
32
|
+
return z.union([z.boolean(), z.stringbool()]).default(false);
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { flag } from "#core/commands/define";
|
|
4
|
+
import { parseFlags } from "#core/commands/runner";
|
|
5
|
+
|
|
6
|
+
const args = z.object({ force: flag() });
|
|
7
|
+
const parse = (argv: string[]) => args.parse(parseFlags(argv)).force;
|
|
8
|
+
|
|
9
|
+
describe("flag()", () => {
|
|
10
|
+
it("is true for a bare flag", () => expect(parse(["--force"])).toBe(true));
|
|
11
|
+
it("reads --flag false as false", () => expect(parse(["--force", "false"])).toBe(false));
|
|
12
|
+
it("reads --flag=0 as false", () => expect(parse(["--force=0"])).toBe(false));
|
|
13
|
+
it("reads --flag true as true", () => expect(parse(["--force", "true"])).toBe(true));
|
|
14
|
+
it("defaults to false", () => expect(parse([])).toBe(false));
|
|
15
|
+
it("rejects anything else", () => expect(() => parse(["--force", "maybe"])).toThrow());
|
|
16
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// .env.test is loaded into process.env, so it holds every variable this app requires
|
|
4
|
+
const saved = { ...process.env };
|
|
5
|
+
|
|
6
|
+
const LOCAL_DEFAULTS = [
|
|
7
|
+
"APP_URL",
|
|
8
|
+
"CORS_ORIGINS",
|
|
9
|
+
"EMAIL_DRIVER",
|
|
10
|
+
"STORAGE_DRIVER",
|
|
11
|
+
"SMTP_URL",
|
|
12
|
+
"KEYCLOAK_URL",
|
|
13
|
+
].filter((key) => saved[key] !== undefined);
|
|
14
|
+
|
|
15
|
+
async function loadEnv(vars: Record<string, string | undefined>) {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
process.env = { ...vars } as NodeJS.ProcessEnv;
|
|
18
|
+
return (await import("#core/config/env")).env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.env = { ...saved };
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("env in production", () => {
|
|
27
|
+
it("loads when every setting is given", async () => {
|
|
28
|
+
const env = await loadEnv({ ...saved, NODE_ENV: "production" });
|
|
29
|
+
expect(env.NODE_ENV).toBe("production");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each(LOCAL_DEFAULTS)("refuses to fall back to the local default for %s", async (key) => {
|
|
33
|
+
await expect(loadEnv({ ...saved, NODE_ENV: "production", [key]: undefined })).rejects.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps local defaults outside production", async () => {
|
|
37
|
+
const env = await loadEnv({ ...saved, NODE_ENV: "development", APP_URL: undefined });
|
|
38
|
+
expect(env.APP_URL).toBe("http://localhost:3000");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -43,7 +43,12 @@ export function createHttpApp(name: AppName, options: { title: string }) {
|
|
|
43
43
|
if (definition.app === name) app.route(`${API_PREFIX}${definition.basePath}`, definition.router);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const openApiConfig = { openapi: "3.1.0", info: { title: options.title, version: "1.0.0" } } as const;
|
|
47
|
+
app.get("/openapi.json", async (c) => {
|
|
48
|
+
const document = app.getOpenAPI31Document(openApiConfig);
|
|
49
|
+
// tatara:slot openapi
|
|
50
|
+
return c.json(document);
|
|
51
|
+
});
|
|
47
52
|
app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
|
|
48
53
|
|
|
49
54
|
app.onError(errorHandler);
|