@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.
- package/dist/bin.mjs +377 -94
- package/package.json +1 -1
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +2 -2
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
- package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
- package/templates/features/authz/files/src/core/authz/index.ts +9 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
- package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
- package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
- package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
- package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
- package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/versions.json +4 -1
- package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
- package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
- package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
- package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
- package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
- package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
- package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
- package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
- package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
- package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
- package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
- package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
- package/templates/features/authz/files/src/core/authz/types.ts +0 -87
- package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
-
import { currentPrincipal } from "
|
|
2
|
+
import { currentPrincipal } from "@tatara/auth";
|
|
3
3
|
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
4
4
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
5
5
|
import { greetingSchema } from "#modules/example/validators/greeting.validators";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
-
import { currentPrincipal, requireAuth } from "
|
|
2
|
+
import { currentPrincipal, requireAuth } from "@tatara/auth";
|
|
3
3
|
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
4
4
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
5
5
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { keycloak } from "#core/auth/keycloak";
|
|
3
3
|
import { defineCommand } from "@tatara/core";
|
|
4
4
|
import { env } from "#core/config/env";
|
|
5
5
|
|
|
@@ -13,7 +13,7 @@ export default defineCommand({
|
|
|
13
13
|
}),
|
|
14
14
|
async run({ user, password, client }) {
|
|
15
15
|
if (env.NODE_ENV === "production") throw new Error("keycloak:token is for development only");
|
|
16
|
-
const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
|
|
16
|
+
const res = await fetch(`${keycloak.issuer}/protocol/openid-connect/token`, {
|
|
17
17
|
method: "POST",
|
|
18
18
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
19
19
|
body: new URLSearchParams({
|
|
@@ -1,58 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { keycloakVerifier } from "@tatara/keycloak";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/** Tests replace the realm's key set with a local one. */
|
|
11
|
-
export function setKeySource(source: JWTVerifyGetKey): void {
|
|
12
|
-
keySource = source;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface KeycloakClaims extends JWTPayload {
|
|
16
|
-
sub: string;
|
|
17
|
-
email?: string;
|
|
18
|
-
name?: string;
|
|
19
|
-
preferred_username?: string;
|
|
20
|
-
azp?: string;
|
|
21
|
-
roles?: string[];
|
|
22
|
-
realm_access?: { roles?: string[] };
|
|
23
|
-
resource_access?: Record<string, { roles?: string[] }>;
|
|
24
|
-
organization?: string[] | Record<string, unknown>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Verifies signature, issuer, audience and expiry. Throws when the token is not valid. */
|
|
28
|
-
export async function verifyAccessToken(token: string): Promise<KeycloakClaims> {
|
|
29
|
-
const { payload } = await jwtVerify(token, keySource, {
|
|
30
|
-
issuer,
|
|
31
|
-
audience: env.KEYCLOAK_AUDIENCE,
|
|
32
|
-
});
|
|
33
|
-
if (typeof payload.sub !== "string") throw new Error("token has no subject");
|
|
34
|
-
return payload as KeycloakClaims;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function rolesOf(claims: KeycloakClaims): string[] {
|
|
38
|
-
const roles = new Set<string>([
|
|
39
|
-
...(claims.roles ?? []),
|
|
40
|
-
...(claims.realm_access?.roles ?? []),
|
|
41
|
-
...(claims.resource_access?.[env.KEYCLOAK_AUDIENCE]?.roles ?? []),
|
|
42
|
-
]);
|
|
43
|
-
return [...roles];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Keycloak organizations put memberships in the `organization` claim; the first one wins. */
|
|
47
|
-
export function organizationOf(claims: KeycloakClaims): string | null {
|
|
48
|
-
const org = claims.organization;
|
|
49
|
-
if (Array.isArray(org)) return org[0] ?? null;
|
|
50
|
-
if (org && typeof org === "object") {
|
|
51
|
-
const [first] = Object.entries(org);
|
|
52
|
-
if (!first) return null;
|
|
53
|
-
const [alias, value] = first;
|
|
54
|
-
const id = (value as { id?: unknown } | undefined)?.id;
|
|
55
|
-
return typeof id === "string" ? id : alias;
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
4
|
+
/** Verifies access tokens from the realm; `keycloakTokens()` in auth.ts uses it. */
|
|
5
|
+
export const keycloak = keycloakVerifier({
|
|
6
|
+
url: env.KEYCLOAK_URL,
|
|
7
|
+
realm: env.KEYCLOAK_REALM,
|
|
8
|
+
audience: env.KEYCLOAK_AUDIENCE,
|
|
9
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateKeyPair, SignJWT } from "jose";
|
|
2
2
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { auth } from "#core/auth/auth";
|
|
4
|
-
import {
|
|
4
|
+
import { keycloak } from "#core/auth/keycloak";
|
|
5
5
|
import { env } from "#core/config/env";
|
|
6
6
|
import { newId } from "@tatara/db";
|
|
7
7
|
import { api } from "#tests/helpers/api";
|
|
@@ -63,7 +63,7 @@ describe("Keycloak access tokens", () => {
|
|
|
63
63
|
const forged = await new SignJWT({ roles: ["admin"] })
|
|
64
64
|
.setProtectedHeader({ alg: "RS256", kid: "test" })
|
|
65
65
|
.setSubject(newId())
|
|
66
|
-
.setIssuer(issuer)
|
|
66
|
+
.setIssuer(keycloak.issuer)
|
|
67
67
|
.setAudience(env.KEYCLOAK_AUDIENCE)
|
|
68
68
|
.setExpirationTime("5m")
|
|
69
69
|
.sign(privateKey);
|
package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { authz } from "#core/authz/index";
|
|
3
3
|
import { api } from "#tests/helpers/api";
|
|
4
4
|
import { keycloakToken } from "#tests/helpers/auth";
|
|
5
5
|
import { resetDatabase } from "#tests/helpers/db";
|
|
@@ -12,7 +12,7 @@ beforeEach(async () => {
|
|
|
12
12
|
|
|
13
13
|
describe("Keycloak access tokens with authz", () => {
|
|
14
14
|
it("lets token roles match built-in authz roles", async () => {
|
|
15
|
-
await syncSystemRoles();
|
|
15
|
+
await authz.syncSystemRoles();
|
|
16
16
|
const admin = await keycloakToken({ roles: ["admin"] });
|
|
17
17
|
const user = await keycloakToken({ roles: ["user"] });
|
|
18
18
|
expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "
|
|
3
|
-
import {
|
|
4
|
-
import { assignRole } from "#core/authz/services/members";
|
|
2
|
+
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
|
|
3
|
+
import { authz } from "#core/authz/index";
|
|
5
4
|
import { defineCommand } from "@tatara/core";
|
|
6
5
|
|
|
7
6
|
export default defineCommand({
|
|
@@ -13,9 +12,10 @@ export default defineCommand({
|
|
|
13
12
|
org: z.string().default(DEFAULT_ORG_ID),
|
|
14
13
|
}),
|
|
15
14
|
async run({ user, role, org }, { logger }) {
|
|
16
|
-
const found = (await store.findRoleByKey(org, role)) ??
|
|
15
|
+
const found = (await authz.store.findRoleByKey(org, role)) ??
|
|
16
|
+
(await authz.store.findRoleByKey(SYSTEM_ORG_ID, role));
|
|
17
17
|
if (!found) throw new Error(`No role "${role}" (run authz:sync-permissions first for built-in roles)`);
|
|
18
|
-
const added = await assignRole(null, org, user, found.id);
|
|
18
|
+
const added = await authz.assignRole(null, org, user, found.id);
|
|
19
19
|
logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
|
|
20
20
|
},
|
|
21
21
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { defineCommand } from "@tatara/core";
|
|
2
|
-
import {
|
|
2
|
+
import { authz } from "#core/authz/index";
|
|
3
3
|
|
|
4
4
|
export default defineCommand({
|
|
5
5
|
name: "authz:sync-permissions",
|
|
6
6
|
description: "Create/update built-in roles so their grants match the code (run on every deploy)",
|
|
7
7
|
async run(_args, { logger }) {
|
|
8
|
-
const result = await syncSystemRoles();
|
|
8
|
+
const result = await authz.syncSystemRoles();
|
|
9
9
|
logger.info(result, "built-in roles in sync");
|
|
10
10
|
},
|
|
11
11
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { authz } from "#core/authz/index";
|
|
2
2
|
import { defineSeed } from "@tatara/db";
|
|
3
3
|
|
|
4
4
|
export default defineSeed({
|
|
5
5
|
name: "authz:system-roles",
|
|
6
6
|
order: -10,
|
|
7
7
|
async run({ logger }) {
|
|
8
|
-
logger.info(await syncSystemRoles(), "built-in roles in sync");
|
|
8
|
+
logger.info(await authz.syncSystemRoles(), "built-in roles in sync");
|
|
9
9
|
},
|
|
10
10
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
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,281 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
auditEntrySchema,
|
|
11
|
-
createRoleSchema,
|
|
12
|
-
idParams,
|
|
13
|
-
memberSchema,
|
|
14
|
-
myPermissionsSchema,
|
|
15
|
-
overlayInputSchema,
|
|
16
|
-
roleSchema,
|
|
17
|
-
updateRoleSchema,
|
|
18
|
-
userOverlayParams,
|
|
19
|
-
userParams,
|
|
20
|
-
userRoleParams,
|
|
21
|
-
} from "#core/authz/validators";
|
|
22
|
-
import { errorResponses, jsonBody, jsonContent, success, successSchema } from "@tatara/http";
|
|
23
|
-
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
24
|
-
import { catalog } from "#generated/permissions";
|
|
25
|
-
|
|
26
|
-
const router = createRouter();
|
|
27
|
-
const tags = ["Authorization"];
|
|
28
|
-
const toOverlay = (o: OverlayRow) => ({
|
|
29
|
-
id: o.id,
|
|
30
|
-
resource: o.resource,
|
|
31
|
-
action: o.action,
|
|
32
|
-
scope: o.scope,
|
|
33
|
-
effect: o.effect,
|
|
34
|
-
reason: o.reason,
|
|
35
|
-
expiresAt: o.expiresAt ? o.expiresAt.toISOString() : null,
|
|
1
|
+
import { authorizationRoutes } from "@tatara/authz";
|
|
2
|
+
import { authz } from "#core/authz/index";
|
|
3
|
+
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
import { defineRoutes } from "#core/http/routes";
|
|
5
|
+
|
|
6
|
+
export default defineRoutes({
|
|
7
|
+
app: "api",
|
|
8
|
+
basePath: "/authorization",
|
|
9
|
+
router: authorizationRoutes<AppEnv>(authz),
|
|
36
10
|
});
|
|
37
|
-
|
|
38
|
-
router.openapi(
|
|
39
|
-
createRoute({
|
|
40
|
-
method: "get",
|
|
41
|
-
path: "/catalog",
|
|
42
|
-
tags,
|
|
43
|
-
summary: "Every permission the API knows",
|
|
44
|
-
middleware: [requireAuth()] as const,
|
|
45
|
-
security: [{ bearerAuth: [] }],
|
|
46
|
-
responses: {
|
|
47
|
-
200: jsonContent(
|
|
48
|
-
successSchema(z.object({ keys: z.array(z.string()), resources: z.record(z.string(), z.unknown()) })),
|
|
49
|
-
"Permission catalog",
|
|
50
|
-
),
|
|
51
|
-
...errorResponses(401),
|
|
52
|
-
},
|
|
53
|
-
}),
|
|
54
|
-
(c) => c.json(success({ keys: catalog.keys, resources: catalog.resources }), 200),
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
router.openapi(
|
|
58
|
-
createRoute({
|
|
59
|
-
method: "get",
|
|
60
|
-
path: "/me",
|
|
61
|
-
tags,
|
|
62
|
-
summary: "The caller's effective permissions in their org",
|
|
63
|
-
middleware: [requireAuth()] as const,
|
|
64
|
-
security: [{ bearerAuth: [] }],
|
|
65
|
-
responses: { 200: jsonContent(successSchema(myPermissionsSchema), "Effective permissions"), ...errorResponses(401) },
|
|
66
|
-
}),
|
|
67
|
-
async (c) => {
|
|
68
|
-
const principal = currentPrincipal(c);
|
|
69
|
-
const permissions = await effectivePermissions(principal);
|
|
70
|
-
return c.json(
|
|
71
|
-
success({
|
|
72
|
-
orgId: orgOf(principal),
|
|
73
|
-
roles: permissions.roles,
|
|
74
|
-
grants: permissions.grants,
|
|
75
|
-
denies: [...permissions.denies],
|
|
76
|
-
}),
|
|
77
|
-
200,
|
|
78
|
-
);
|
|
79
|
-
},
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
router.openapi(
|
|
83
|
-
createRoute({
|
|
84
|
-
method: "get",
|
|
85
|
-
path: "/roles",
|
|
86
|
-
tags,
|
|
87
|
-
summary: "Built-in roles and the org's custom roles",
|
|
88
|
-
middleware: [requirePermission("role:read")] as const,
|
|
89
|
-
security: [{ bearerAuth: ["role:read"] }],
|
|
90
|
-
responses: { 200: jsonContent(successSchema(z.array(roleSchema)), "Roles"), ...errorResponses(401, 403) },
|
|
91
|
-
}),
|
|
92
|
-
async (c) => c.json(success(await listRoles(orgOf(currentPrincipal(c)))), 200),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
router.openapi(
|
|
96
|
-
createRoute({
|
|
97
|
-
method: "post",
|
|
98
|
-
path: "/roles",
|
|
99
|
-
tags,
|
|
100
|
-
summary: "Create a custom role",
|
|
101
|
-
middleware: [requirePermission("role:create")] as const,
|
|
102
|
-
security: [{ bearerAuth: ["role:create"] }],
|
|
103
|
-
request: { body: jsonBody(createRoleSchema, "The role") },
|
|
104
|
-
responses: {
|
|
105
|
-
201: jsonContent(successSchema(roleSchema), "Created"),
|
|
106
|
-
...errorResponses(400, 409),
|
|
107
|
-
...errorResponses(401, 403),
|
|
108
|
-
},
|
|
109
|
-
}),
|
|
110
|
-
async (c) => {
|
|
111
|
-
const principal = currentPrincipal(c);
|
|
112
|
-
const role = await createRole(principal.userId, orgOf(principal), c.req.valid("json"));
|
|
113
|
-
return c.json(success(role), 201);
|
|
114
|
-
},
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
router.openapi(
|
|
118
|
-
createRoute({
|
|
119
|
-
method: "patch",
|
|
120
|
-
path: "/roles/{id}",
|
|
121
|
-
tags,
|
|
122
|
-
summary: "Update a custom role",
|
|
123
|
-
middleware: [requirePermission("role:update")] as const,
|
|
124
|
-
security: [{ bearerAuth: ["role:update"] }],
|
|
125
|
-
request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
|
|
126
|
-
responses: {
|
|
127
|
-
200: jsonContent(successSchema(roleSchema), "Updated"),
|
|
128
|
-
...errorResponses(400, 404),
|
|
129
|
-
...errorResponses(401, 403),
|
|
130
|
-
},
|
|
131
|
-
}),
|
|
132
|
-
async (c) => {
|
|
133
|
-
const principal = currentPrincipal(c);
|
|
134
|
-
const role = await updateRole(principal.userId, orgOf(principal), c.req.valid("param").id, c.req.valid("json"));
|
|
135
|
-
return c.json(success(role), 200);
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
router.openapi(
|
|
140
|
-
createRoute({
|
|
141
|
-
method: "delete",
|
|
142
|
-
path: "/roles/{id}",
|
|
143
|
-
tags,
|
|
144
|
-
summary: "Delete a custom role that no member holds",
|
|
145
|
-
middleware: [requirePermission("role:delete")] as const,
|
|
146
|
-
security: [{ bearerAuth: ["role:delete"] }],
|
|
147
|
-
request: { params: idParams },
|
|
148
|
-
responses: { 204: { description: "Deleted" }, ...errorResponses(400, 404, 409), ...errorResponses(401, 403) },
|
|
149
|
-
}),
|
|
150
|
-
async (c) => {
|
|
151
|
-
const principal = currentPrincipal(c);
|
|
152
|
-
await deleteRole(principal.userId, orgOf(principal), c.req.valid("param").id);
|
|
153
|
-
return c.body(null, 204);
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
router.openapi(
|
|
158
|
-
createRoute({
|
|
159
|
-
method: "get",
|
|
160
|
-
path: "/members/{userId}",
|
|
161
|
-
tags,
|
|
162
|
-
summary: "A member's roles and overlays",
|
|
163
|
-
middleware: [requirePermission("member:read")] as const,
|
|
164
|
-
security: [{ bearerAuth: ["member:read"] }],
|
|
165
|
-
request: { params: userParams },
|
|
166
|
-
responses: { 200: jsonContent(successSchema(memberSchema), "Member"), ...errorResponses(401, 403) },
|
|
167
|
-
}),
|
|
168
|
-
async (c) => {
|
|
169
|
-
const member = await getMember(orgOf(currentPrincipal(c)), c.req.valid("param").userId);
|
|
170
|
-
return c.json(success({ ...member, overlays: member.overlays.map(toOverlay) }), 200);
|
|
171
|
-
},
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
router.openapi(
|
|
175
|
-
createRoute({
|
|
176
|
-
method: "post",
|
|
177
|
-
path: "/members/{userId}/roles",
|
|
178
|
-
tags,
|
|
179
|
-
summary: "Assign a role to a member",
|
|
180
|
-
middleware: [requirePermission("member:update")] as const,
|
|
181
|
-
security: [{ bearerAuth: ["member:update"] }],
|
|
182
|
-
request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
|
|
183
|
-
responses: {
|
|
184
|
-
200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
|
|
185
|
-
...errorResponses(404),
|
|
186
|
-
...errorResponses(401, 403),
|
|
187
|
-
},
|
|
188
|
-
}),
|
|
189
|
-
async (c) => {
|
|
190
|
-
const principal = currentPrincipal(c);
|
|
191
|
-
const assigned = await assignRole(
|
|
192
|
-
principal.userId,
|
|
193
|
-
orgOf(principal),
|
|
194
|
-
c.req.valid("param").userId,
|
|
195
|
-
c.req.valid("json").roleId,
|
|
196
|
-
);
|
|
197
|
-
return c.json(success({ assigned }), 200);
|
|
198
|
-
},
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
router.openapi(
|
|
202
|
-
createRoute({
|
|
203
|
-
method: "delete",
|
|
204
|
-
path: "/members/{userId}/roles/{roleId}",
|
|
205
|
-
tags,
|
|
206
|
-
summary: "Revoke a role from a member",
|
|
207
|
-
middleware: [requirePermission("member:update")] as const,
|
|
208
|
-
security: [{ bearerAuth: ["member:update"] }],
|
|
209
|
-
request: { params: userRoleParams },
|
|
210
|
-
responses: { 204: { description: "Revoked" }, ...errorResponses(404), ...errorResponses(401, 403) },
|
|
211
|
-
}),
|
|
212
|
-
async (c) => {
|
|
213
|
-
const principal = currentPrincipal(c);
|
|
214
|
-
const { userId, roleId } = c.req.valid("param");
|
|
215
|
-
await revokeRole(principal.userId, orgOf(principal), userId, roleId);
|
|
216
|
-
return c.body(null, 204);
|
|
217
|
-
},
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
router.openapi(
|
|
221
|
-
createRoute({
|
|
222
|
-
method: "post",
|
|
223
|
-
path: "/members/{userId}/overlays",
|
|
224
|
-
tags,
|
|
225
|
-
summary: "Grant or deny one permission to a member",
|
|
226
|
-
middleware: [requirePermission("member:update")] as const,
|
|
227
|
-
security: [{ bearerAuth: ["member:update"] }],
|
|
228
|
-
request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
|
|
229
|
-
responses: {
|
|
230
|
-
201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
|
|
231
|
-
...errorResponses(400),
|
|
232
|
-
...errorResponses(401, 403),
|
|
233
|
-
},
|
|
234
|
-
}),
|
|
235
|
-
async (c) => {
|
|
236
|
-
const principal = currentPrincipal(c);
|
|
237
|
-
const body = c.req.valid("json");
|
|
238
|
-
const id = await addOverlay(principal.userId, orgOf(principal), c.req.valid("param").userId, {
|
|
239
|
-
...body,
|
|
240
|
-
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
|
241
|
-
});
|
|
242
|
-
return c.json(success({ id }), 201);
|
|
243
|
-
},
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
router.openapi(
|
|
247
|
-
createRoute({
|
|
248
|
-
method: "delete",
|
|
249
|
-
path: "/members/{userId}/overlays/{overlayId}",
|
|
250
|
-
tags,
|
|
251
|
-
summary: "Remove an overlay",
|
|
252
|
-
middleware: [requirePermission("member:update")] as const,
|
|
253
|
-
security: [{ bearerAuth: ["member:update"] }],
|
|
254
|
-
request: { params: userOverlayParams },
|
|
255
|
-
responses: { 204: { description: "Removed" }, ...errorResponses(404), ...errorResponses(401, 403) },
|
|
256
|
-
}),
|
|
257
|
-
async (c) => {
|
|
258
|
-
const principal = currentPrincipal(c);
|
|
259
|
-
const { userId, overlayId } = c.req.valid("param");
|
|
260
|
-
await removeOverlay(principal.userId, orgOf(principal), userId, overlayId);
|
|
261
|
-
return c.body(null, 204);
|
|
262
|
-
},
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
router.openapi(
|
|
266
|
-
createRoute({
|
|
267
|
-
method: "get",
|
|
268
|
-
path: "/audit",
|
|
269
|
-
tags,
|
|
270
|
-
summary: "Recent authorization changes in the org",
|
|
271
|
-
middleware: [requirePermission("audit_log:read")] as const,
|
|
272
|
-
security: [{ bearerAuth: ["audit_log:read"] }],
|
|
273
|
-
responses: { 200: jsonContent(successSchema(z.array(auditEntrySchema)), "Audit log"), ...errorResponses(401, 403) },
|
|
274
|
-
}),
|
|
275
|
-
async (c) => {
|
|
276
|
-
const rows = await listAudit(orgOf(currentPrincipal(c)));
|
|
277
|
-
return c.json(success(rows.map((r) => ({ ...r, at: r.at.toISOString() }))), 200);
|
|
278
|
-
},
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
export default defineRoutes({ app: "api", basePath: "/authorization", router });
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { allows } from "
|
|
3
|
-
import type { EffectivePermissions } from "#core/authz/types";
|
|
2
|
+
import { allows, type EffectivePermissions } from "@tatara/authz";
|
|
4
3
|
import { api } from "#tests/helpers/api";
|
|
5
4
|
import { asUser, authenticate } from "#tests/helpers/auth";
|
|
6
5
|
import { applyTestAccess } from "#tests/helpers/authz";
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "
|
|
2
|
-
import {
|
|
3
|
-
import { addOverlay, assignRole } from "#core/authz/services/members";
|
|
4
|
-
import { syncSystemRoles } from "#core/authz/services/sync";
|
|
1
|
+
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
|
|
2
|
+
import { authz } from "#core/authz/index";
|
|
5
3
|
import type { SystemRoleKey } from "#core/authz/system-roles";
|
|
6
4
|
import type { PermissionKey } from "#generated/permissions";
|
|
7
5
|
|
|
@@ -17,14 +15,14 @@ export async function applyTestAccess(userId: string, access: TestAccess): Promi
|
|
|
17
15
|
const orgId = access.orgId ?? DEFAULT_ORG_ID;
|
|
18
16
|
for (const key of access.permissions ?? []) {
|
|
19
17
|
const [resource = "", action = ""] = key.split(":");
|
|
20
|
-
await addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
|
|
18
|
+
await authz.addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
|
|
21
19
|
}
|
|
22
20
|
if (access.systemRoles?.length) {
|
|
23
|
-
await syncSystemRoles();
|
|
21
|
+
await authz.syncSystemRoles();
|
|
24
22
|
for (const roleKey of access.systemRoles) {
|
|
25
|
-
const role = await store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
|
|
23
|
+
const role = await authz.store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
|
|
26
24
|
if (!role) throw new Error(`Unknown built-in role ${roleKey}`);
|
|
27
|
-
await assignRole(null, orgId, userId, role.id);
|
|
25
|
+
await authz.assignRole(null, orgId, userId, role.id);
|
|
28
26
|
}
|
|
29
27
|
}
|
|
30
28
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
|
2
|
-
import type { OverlayEffect, Scope } from "
|
|
3
|
-
import { SYSTEM_ORG_ID } from "
|
|
2
|
+
import type { OverlayEffect, Scope } from "@tatara/authz";
|
|
3
|
+
import { SYSTEM_ORG_ID } from "@tatara/authz";
|
|
4
4
|
import {
|
|
5
5
|
authzAuditLog,
|
|
6
6
|
authzMemberRoles,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
authzRolePermissions,
|
|
9
9
|
authzRoles,
|
|
10
10
|
} from "#core/authz/db/authz.schema";
|
|
11
|
-
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "
|
|
11
|
+
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
|
|
12
12
|
import { db } from "#core/db/client";
|
|
13
13
|
import { newId } from "@tatara/db";
|
|
14
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { OverlayEffect, Scope } from "
|
|
2
|
-
import { SYSTEM_ORG_ID } from "
|
|
3
|
-
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "
|
|
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
4
|
import { db } from "#core/db/client";
|
|
5
5
|
import { newId } from "@tatara/db";
|
|
6
6
|
|
|
@@ -1,36 +1,12 @@
|
|
|
1
|
+
import { createOutbox } from "@tatara/auth";
|
|
1
2
|
import { smsSender } from "#core/auth/sms";
|
|
2
3
|
import { env } from "#core/config/env";
|
|
3
4
|
// tatara:slot imports
|
|
4
5
|
|
|
5
|
-
export interface OutboxMessage {
|
|
6
|
-
channel: "email" | "sms";
|
|
7
|
-
to: string;
|
|
8
|
-
subject?: string;
|
|
9
|
-
text: string;
|
|
10
|
-
/** one-time code, when the message carries one */
|
|
11
|
-
code?: string;
|
|
12
|
-
sentAt: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const outbox: OutboxMessage[] = [];
|
|
16
|
-
|
|
17
6
|
/** Only with DEV_OUTBOX=true in development or test: the outbox exposes sign-in codes. */
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Messages are kept here so tests and local sign-in can read codes. */
|
|
23
|
-
function record(message: Omit<OutboxMessage, "sentAt">) {
|
|
24
|
-
if (!outboxEnabled()) return;
|
|
25
|
-
outbox.push({ ...message, sentAt: new Date().toISOString() });
|
|
26
|
-
if (outbox.length > 200) outbox.shift();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Newest first. Empty unless the outbox is enabled. */
|
|
30
|
-
export function readOutbox(to?: string): OutboxMessage[] {
|
|
31
|
-
if (!outboxEnabled()) return [];
|
|
32
|
-
return outbox.filter((m) => !to || m.to === to).reverse();
|
|
33
|
-
}
|
|
7
|
+
export const outbox = createOutbox({
|
|
8
|
+
enabled: env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test"),
|
|
9
|
+
});
|
|
34
10
|
|
|
35
11
|
export async function deliverEmail(message: {
|
|
36
12
|
to: string;
|
|
@@ -38,11 +14,11 @@ export async function deliverEmail(message: {
|
|
|
38
14
|
text: string;
|
|
39
15
|
code?: string;
|
|
40
16
|
}): Promise<void> {
|
|
41
|
-
record({ channel: "email", ...message });
|
|
17
|
+
outbox.record({ channel: "email", ...message });
|
|
42
18
|
// tatara:slot email-delivery
|
|
43
19
|
}
|
|
44
20
|
|
|
45
21
|
export async function deliverSms(message: { to: string; text: string; code?: string }): Promise<void> {
|
|
46
|
-
record({ channel: "sms", ...message });
|
|
22
|
+
outbox.record({ channel: "sms", ...message });
|
|
47
23
|
await smsSender.send(message.to, message.text);
|
|
48
24
|
}
|
|
@@ -1,41 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { principalMiddleware, sessionPrincipal } from "@tatara/auth";
|
|
2
2
|
import { auth } from "#core/auth/auth";
|
|
3
3
|
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
// tatara:slot imports
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!orgId) return null;
|
|
11
|
-
const { adapter } = await auth.$context;
|
|
12
|
-
const member = await adapter.findOne({
|
|
13
|
-
model: "member",
|
|
14
|
-
where: [
|
|
15
|
-
{ field: "organizationId", value: orgId },
|
|
16
|
-
{ field: "userId", value: userId },
|
|
17
|
-
],
|
|
18
|
-
});
|
|
19
|
-
return member ? orgId : null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Resolves the session (cookie or bearer token) into `c.var.principal`. */
|
|
23
|
-
export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
|
|
24
|
-
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
25
|
-
if (session) {
|
|
26
|
-
const user = session.user as typeof session.user & { role?: string | null };
|
|
27
|
-
const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
|
|
28
|
-
c.set("principal", {
|
|
29
|
-
userId: user.id,
|
|
30
|
-
orgId: await activeMembership(user.id, s.activeOrganizationId),
|
|
31
|
-
roles: (user.role ?? "user")
|
|
32
|
-
.split(",")
|
|
33
|
-
.map((role) => role.trim())
|
|
34
|
-
.filter(Boolean),
|
|
35
|
-
kind: "user",
|
|
36
|
-
claims: {},
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
// tatara:slot no-session
|
|
40
|
-
await next();
|
|
41
|
-
});
|
|
6
|
+
/** Sets `c.var.principal` from the first resolver that recognises the request's credentials. */
|
|
7
|
+
export const attachPrincipal = principalMiddleware<AppEnv>([
|
|
8
|
+
(c) => sessionPrincipal(auth, c.req.raw.headers),
|
|
9
|
+
// tatara:slot resolvers
|
|
10
|
+
]);
|