@tatara/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/bin.mjs +2910 -0
- package/package.json +32 -3
- package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
- package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
- package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
- package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
- package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
- package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
- package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
- package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
- package/templates/features/authz/files/src/core/authz/constants.ts +28 -0
- package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
- package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
- package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
- package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
- package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
- package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
- package/templates/features/authz/files/src/core/authz/services/members.ts +68 -0
- package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
- package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
- package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
- package/templates/features/authz/files/src/core/authz/types.ts +85 -0
- package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
- package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +262 -0
- package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
- package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
- package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +229 -0
- package/templates/features/base/files/AGENTS.md +69 -0
- package/templates/features/base/files/CLAUDE.md +1 -0
- package/templates/features/base/files/Dockerfile +15 -0
- package/templates/features/base/files/README.md +31 -0
- package/templates/features/base/files/_github/workflows/ci.yml +16 -0
- package/templates/features/base/files/biome.template.json +20 -0
- package/templates/features/base/files/dockerignore +8 -0
- package/templates/features/base/files/gitignore +9 -0
- package/templates/features/base/files/src/apps/api/app.ts +3 -0
- package/templates/features/base/files/src/apps/api/server.ts +5 -0
- package/templates/features/base/files/src/apps/cli/index.ts +6 -0
- package/templates/features/base/files/src/core/commands/define.ts +25 -0
- package/templates/features/base/files/src/core/commands/runner.ts +54 -0
- package/templates/features/base/files/src/core/http/app.ts +52 -0
- package/templates/features/base/files/src/core/http/context.ts +18 -0
- package/templates/features/base/files/src/core/http/errors.ts +51 -0
- package/templates/features/base/files/src/core/http/health.ts +25 -0
- package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
- package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
- package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
- package/templates/features/base/files/src/core/http/response.ts +67 -0
- package/templates/features/base/files/src/core/http/routes.ts +28 -0
- package/templates/features/base/files/src/core/http/server.ts +15 -0
- package/templates/features/base/files/src/core/logging/logger.ts +17 -0
- package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
- package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
- package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
- package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
- package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
- package/templates/features/base/files/tests/helpers/api.ts +54 -0
- package/templates/features/base/files/tests/setup.ts +2 -0
- package/templates/features/base/files/tsconfig.json +20 -0
- package/templates/features/base/files/tsdown.config.ts +15 -0
- package/templates/features/base/files/vitest.config.ts +16 -0
- package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
- package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +23 -0
- package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
- package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
- package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
- package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
- package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
- package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
- package/templates/features/db/files/src/core/db/id.ts +13 -0
- package/templates/features/db/files/src/core/db/seed.ts +14 -0
- package/templates/features/db/files/tests/global-setup.ts +27 -0
- package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
- package/templates/features/db/files/tests/helpers/db.ts +7 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
- package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
- package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
- package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
- package/templates/features/email/files/src/core/email/send.ts +22 -0
- package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
- package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
- package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
- package/templates/features/i18n/files/src/locales/ar.json +10 -0
- package/templates/features/i18n/files/src/locales/en.json +10 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
- package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
- package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
- package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
- package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
- package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
- package/templates/features/keycloak/files/keycloak/realm.json +83 -0
- package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
- package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
- package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
- package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
- package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
- package/templates/features/prisma/files/prisma.config.ts +10 -0
- package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
- package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
- package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
- package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
- package/templates/features/storage/files/src/core/storage/index.ts +8 -0
- package/templates/features/storage/files/src/core/storage/local.ts +34 -0
- package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
- package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +126 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
- package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
- package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
- package/templates/versions.json +34 -0
- package/README.md +0 -3
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
+
import { currentPrincipal, requireAuth } from "#core/auth/middleware";
|
|
3
|
+
import { requirePermission } from "#core/authz/middleware";
|
|
4
|
+
import { effectivePermissions, orgOf } from "#core/authz/resolver";
|
|
5
|
+
import { listAudit } from "#core/authz/services/audit";
|
|
6
|
+
import { addOverlay, assignRole, getMember, removeOverlay, revokeRole } from "#core/authz/services/members";
|
|
7
|
+
import { createRole, deleteRole, listRoles, updateRole } from "#core/authz/services/roles";
|
|
8
|
+
import type { OverlayRow } from "#core/authz/types";
|
|
9
|
+
import {
|
|
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, jsonContent, success, successSchema } from "#core/http/response";
|
|
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,
|
|
36
|
+
});
|
|
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: jsonContent(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: jsonContent(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: jsonContent(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: jsonContent(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 });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AuditChangeType } from "#core/authz/constants";
|
|
2
|
+
import { store } from "#core/authz/db/store";
|
|
3
|
+
|
|
4
|
+
export async function audit(input: {
|
|
5
|
+
orgId: string;
|
|
6
|
+
actorId: string;
|
|
7
|
+
targetUserId?: string | null;
|
|
8
|
+
changeType: AuditChangeType;
|
|
9
|
+
before?: unknown;
|
|
10
|
+
after?: unknown;
|
|
11
|
+
}): Promise<void> {
|
|
12
|
+
await store.writeAudit({
|
|
13
|
+
orgId: input.orgId,
|
|
14
|
+
actorId: input.actorId,
|
|
15
|
+
targetUserId: input.targetUserId ?? null,
|
|
16
|
+
changeType: input.changeType,
|
|
17
|
+
before: input.before,
|
|
18
|
+
after: input.after,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function listAudit(orgId: string, limit = 50) {
|
|
23
|
+
return store.listAudit(orgId, limit);
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { assertGrantValid, InvalidGrantError, META_RESOURCES } from "#core/authz/catalog";
|
|
2
|
+
import type { Scope } from "#core/authz/constants";
|
|
3
|
+
import type { PermissionGrant } from "#core/authz/types";
|
|
4
|
+
import { BusinessRuleError } from "#core/http/errors";
|
|
5
|
+
import { catalog } from "#generated/permissions";
|
|
6
|
+
|
|
7
|
+
/** Validates grants against the catalog; custom roles and overlays may not grant meta resources. */
|
|
8
|
+
export function validateGrants(
|
|
9
|
+
grants: { resource: string; action: string; scope?: string | null }[],
|
|
10
|
+
options: { allowMeta: boolean },
|
|
11
|
+
): PermissionGrant[] {
|
|
12
|
+
return grants.map((grant) => {
|
|
13
|
+
try {
|
|
14
|
+
assertGrantValid(catalog, grant);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error instanceof InvalidGrantError) {
|
|
17
|
+
throw new BusinessRuleError("INVALID_GRANT", error.message);
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
if (!options.allowMeta && META_RESOURCES.has(grant.resource)) {
|
|
22
|
+
throw new BusinessRuleError(
|
|
23
|
+
"META_GRANT_FORBIDDEN",
|
|
24
|
+
`${grant.resource}:${grant.action} can only be granted by built-in roles`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return { resource: grant.resource, action: grant.action, scope: (grant.scope as Scope) ?? null };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { invalidateMember } from "#core/authz/cache";
|
|
2
|
+
import type { OverlayEffect, Scope } from "#core/authz/constants";
|
|
3
|
+
import { store } from "#core/authz/db/store";
|
|
4
|
+
import { audit } from "#core/authz/services/audit";
|
|
5
|
+
import { validateGrants } from "#core/authz/services/grants";
|
|
6
|
+
import { NotFoundError } from "#core/http/errors";
|
|
7
|
+
|
|
8
|
+
export async function getMember(orgId: string, userId: string) {
|
|
9
|
+
const [roles, overlays] = await Promise.all([
|
|
10
|
+
store.memberRoles(orgId, userId),
|
|
11
|
+
store.listOverlays(orgId, userId),
|
|
12
|
+
]);
|
|
13
|
+
return { userId, orgId, roles, overlays };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function assignRole(actorId: string | null, orgId: string, userId: string, roleId: string) {
|
|
17
|
+
const role = await store.findRole(orgId, roleId);
|
|
18
|
+
if (!role) throw new NotFoundError("Role");
|
|
19
|
+
const added = await store.assignRole(orgId, userId, roleId, actorId);
|
|
20
|
+
invalidateMember(userId, orgId);
|
|
21
|
+
if (added && actorId) {
|
|
22
|
+
await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_assigned", after: { roleId, key: role.key } });
|
|
23
|
+
}
|
|
24
|
+
return added;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function revokeRole(actorId: string, orgId: string, userId: string, roleId: string) {
|
|
28
|
+
const removed = await store.revokeRole(orgId, userId, roleId);
|
|
29
|
+
if (!removed) throw new NotFoundError("Role assignment");
|
|
30
|
+
invalidateMember(userId, orgId);
|
|
31
|
+
await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_revoked", before: { roleId } });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OverlayInput {
|
|
35
|
+
resource: string;
|
|
36
|
+
action: string;
|
|
37
|
+
scope?: Scope | null;
|
|
38
|
+
effect: OverlayEffect;
|
|
39
|
+
reason?: string | null;
|
|
40
|
+
expiresAt?: Date | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per-user grant or deny, optionally expiring. Deny always wins over any grant. */
|
|
44
|
+
export async function addOverlay(actorId: string | null, orgId: string, userId: string, input: OverlayInput) {
|
|
45
|
+
const [grant] = validateGrants([input], { allowMeta: input.effect === "deny" });
|
|
46
|
+
if (!grant) throw new NotFoundError("Permission");
|
|
47
|
+
const id = await store.addOverlay({
|
|
48
|
+
orgId,
|
|
49
|
+
userId,
|
|
50
|
+
...grant,
|
|
51
|
+
effect: input.effect,
|
|
52
|
+
grantedBy: actorId,
|
|
53
|
+
reason: input.reason ?? null,
|
|
54
|
+
expiresAt: input.expiresAt ?? null,
|
|
55
|
+
});
|
|
56
|
+
invalidateMember(userId, orgId);
|
|
57
|
+
if (actorId) {
|
|
58
|
+
await audit({ orgId, actorId, targetUserId: userId, changeType: "overlay_granted", after: { id, ...grant, effect: input.effect } });
|
|
59
|
+
}
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function removeOverlay(actorId: string, orgId: string, userId: string, overlayId: string) {
|
|
64
|
+
const removed = await store.removeOverlay(orgId, userId, overlayId);
|
|
65
|
+
if (!removed) throw new NotFoundError("Overlay");
|
|
66
|
+
invalidateMember(userId, orgId);
|
|
67
|
+
await audit({ orgId, actorId, targetUserId: userId, changeType: "overlay_revoked", before: removed });
|
|
68
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { invalidateAll } from "#core/authz/cache";
|
|
2
|
+
import { store } from "#core/authz/db/store";
|
|
3
|
+
import { audit } from "#core/authz/services/audit";
|
|
4
|
+
import { validateGrants } from "#core/authz/services/grants";
|
|
5
|
+
import { SYSTEM_ROLE_KEYS } from "#core/authz/system-roles";
|
|
6
|
+
import type { RoleRow } from "#core/authz/types";
|
|
7
|
+
import { BusinessRuleError, ConflictError, NotFoundError } from "#core/http/errors";
|
|
8
|
+
|
|
9
|
+
export interface RoleInput {
|
|
10
|
+
key: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string | null;
|
|
13
|
+
grants: { resource: string; action: string; scope?: string | null }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function listRoles(orgId: string): Promise<RoleRow[]> {
|
|
17
|
+
return store.listRoles(orgId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function customRole(orgId: string, roleId: string): Promise<RoleRow> {
|
|
21
|
+
const role = await store.findRole(orgId, roleId);
|
|
22
|
+
if (!role) throw new NotFoundError("Role");
|
|
23
|
+
if (role.isSystem) throw new BusinessRuleError("SYSTEM_ROLE_IMMUTABLE", "Built-in roles cannot be changed");
|
|
24
|
+
return role;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createRole(actorId: string, orgId: string, input: RoleInput): Promise<RoleRow> {
|
|
28
|
+
if (SYSTEM_ROLE_KEYS.has(input.key)) {
|
|
29
|
+
throw new ConflictError(`"${input.key}" is a built-in role key`);
|
|
30
|
+
}
|
|
31
|
+
if (await store.findRoleByKey(orgId, input.key)) throw new ConflictError(`Role "${input.key}" exists`);
|
|
32
|
+
const grants = validateGrants(input.grants, { allowMeta: false });
|
|
33
|
+
const id = await store.createRole({
|
|
34
|
+
orgId,
|
|
35
|
+
key: input.key,
|
|
36
|
+
name: input.name,
|
|
37
|
+
description: input.description ?? null,
|
|
38
|
+
isSystem: false,
|
|
39
|
+
grants,
|
|
40
|
+
});
|
|
41
|
+
const role = await store.findRole(orgId, id);
|
|
42
|
+
if (!role) throw new NotFoundError("Role");
|
|
43
|
+
await audit({ orgId, actorId, changeType: "role_created", after: role });
|
|
44
|
+
return role;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function updateRole(
|
|
48
|
+
actorId: string,
|
|
49
|
+
orgId: string,
|
|
50
|
+
roleId: string,
|
|
51
|
+
input: Partial<Omit<RoleInput, "key">>,
|
|
52
|
+
): Promise<RoleRow> {
|
|
53
|
+
const before = await customRole(orgId, roleId);
|
|
54
|
+
await store.updateRole(roleId, {
|
|
55
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
56
|
+
...(input.description === undefined ? {} : { description: input.description }),
|
|
57
|
+
...(input.grants ? { grants: validateGrants(input.grants, { allowMeta: false }) } : {}),
|
|
58
|
+
});
|
|
59
|
+
invalidateAll();
|
|
60
|
+
const after = await customRole(orgId, roleId);
|
|
61
|
+
await audit({ orgId, actorId, changeType: "role_updated", before, after });
|
|
62
|
+
return after;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function deleteRole(actorId: string, orgId: string, roleId: string): Promise<void> {
|
|
66
|
+
const role = await customRole(orgId, roleId);
|
|
67
|
+
if (role.memberCount > 0) {
|
|
68
|
+
throw new ConflictError(`Role "${role.key}" is assigned to ${role.memberCount} member(s)`);
|
|
69
|
+
}
|
|
70
|
+
await store.deleteRole(roleId);
|
|
71
|
+
invalidateAll();
|
|
72
|
+
await audit({ orgId, actorId, changeType: "role_deleted", before: role });
|
|
73
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { invalidateAll } from "#core/authz/cache";
|
|
2
|
+
import { SYSTEM_ORG_ID } from "#core/authz/constants";
|
|
3
|
+
import { store } from "#core/authz/db/store";
|
|
4
|
+
import { validateGrants } from "#core/authz/services/grants";
|
|
5
|
+
import { SYSTEM_ROLES } from "#core/authz/system-roles";
|
|
6
|
+
import { grantKey } from "#core/authz/types";
|
|
7
|
+
import { catalog } from "#generated/permissions";
|
|
8
|
+
|
|
9
|
+
/** Creates or updates every built-in role so its grants match the code. Idempotent. */
|
|
10
|
+
export async function syncSystemRoles(): Promise<{ created: string[]; updated: string[] }> {
|
|
11
|
+
const created: string[] = [];
|
|
12
|
+
const updated: string[] = [];
|
|
13
|
+
for (const meta of SYSTEM_ROLES) {
|
|
14
|
+
const grants = validateGrants(
|
|
15
|
+
(catalog.roleGrants[meta.key] ?? []).map(([resource, action, scope]) => ({ resource, action, scope })),
|
|
16
|
+
{ allowMeta: true },
|
|
17
|
+
);
|
|
18
|
+
const existing = await store.findRoleByKey(SYSTEM_ORG_ID, meta.key);
|
|
19
|
+
if (!existing) {
|
|
20
|
+
await store.createRole({ orgId: SYSTEM_ORG_ID, key: meta.key, name: meta.name, description: meta.description, isSystem: true, grants });
|
|
21
|
+
created.push(meta.key);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const want = new Set(grants.map(grantKey));
|
|
25
|
+
const have = new Set(existing.grants.map(grantKey));
|
|
26
|
+
const same = want.size === have.size && [...want].every((k) => have.has(k));
|
|
27
|
+
if (!same || existing.name !== meta.name || existing.description !== meta.description) {
|
|
28
|
+
await store.updateRole(existing.id, { name: meta.name, description: meta.description, grants });
|
|
29
|
+
updated.push(meta.key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (created.length || updated.length) invalidateAll();
|
|
33
|
+
return { created, updated };
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Built-in roles. Add a key here, then grant it permissions in module permission files. */
|
|
2
|
+
export const SYSTEM_ROLES = [
|
|
3
|
+
{ key: "admin", name: "Admin", description: "Full access, including roles and members." },
|
|
4
|
+
{ key: "member", name: "Member", description: "Default role for every member." },
|
|
5
|
+
] as const;
|
|
6
|
+
|
|
7
|
+
export type SystemRoleKey = (typeof SYSTEM_ROLES)[number]["key"];
|
|
8
|
+
|
|
9
|
+
export const SYSTEM_ROLE_KEYS: ReadonlySet<string> = new Set(SYSTEM_ROLES.map((r) => r.key));
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { allows } from "#core/authz/resolver";
|
|
3
|
+
import type { EffectivePermissions } from "#core/authz/types";
|
|
4
|
+
import { api } from "#tests/helpers/api";
|
|
5
|
+
import { asUser, authenticate } from "#tests/helpers/auth";
|
|
6
|
+
import { applyTestAccess } from "#tests/helpers/authz";
|
|
7
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
8
|
+
|
|
9
|
+
const set = (grants: EffectivePermissions["grants"], denies: string[] = []): EffectivePermissions => ({
|
|
10
|
+
grants,
|
|
11
|
+
denies: new Set(denies),
|
|
12
|
+
roles: [],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("resolver", () => {
|
|
16
|
+
it("lets a deny overlay win over any grant", () => {
|
|
17
|
+
const p = set([{ resource: "role", action: "read", scope: null }], ["role:read:*"]);
|
|
18
|
+
expect(allows(p, "role", "read")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not let a denied wide grant satisfy a narrower check", () => {
|
|
22
|
+
const p = set([{ resource: "doc", action: "read", scope: "org" }], ["doc:read:org"]);
|
|
23
|
+
expect(allows(p, "doc", "read", "team")).toBe(false);
|
|
24
|
+
expect(allows(p, "doc", "read")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("honours the scope hierarchy org > team > own", () => {
|
|
28
|
+
const p = set([{ resource: "doc", action: "read", scope: "team" }]);
|
|
29
|
+
expect(allows(p, "doc", "read", "own")).toBe(true);
|
|
30
|
+
expect(allows(p, "doc", "read", "team")).toBe(true);
|
|
31
|
+
expect(allows(p, "doc", "read", "org")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("/api/authorization", () => {
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await resetDatabase();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("forbids listing roles without role:read", async () => {
|
|
41
|
+
const res = await api.get("/api/authorization/roles", { headers: await asUser() });
|
|
42
|
+
expect(res.status).toBe(403);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("lets an admin list built-in roles", async () => {
|
|
46
|
+
const res = await api.get<{ data: { key: string; isSystem: boolean }[] }>(
|
|
47
|
+
"/api/authorization/roles",
|
|
48
|
+
{ headers: await asUser({ systemRoles: ["admin"] }) },
|
|
49
|
+
);
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
expect(res.body.data.filter((r) => r.isSystem).map((r) => r.key)).toEqual(["admin", "member"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects custom roles that grant authorization itself", async () => {
|
|
55
|
+
const res = await api.post(
|
|
56
|
+
"/api/authorization/roles",
|
|
57
|
+
{ key: "sneaky", name: "Sneaky", grants: [{ resource: "role", action: "update" }] },
|
|
58
|
+
{ headers: await asUser({ systemRoles: ["admin"] }) },
|
|
59
|
+
);
|
|
60
|
+
expect(res.status).toBe(400);
|
|
61
|
+
expect(res.body).toMatchObject({ code: "META_GRANT_FORBIDDEN" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("creates, assigns and audits a custom role", async () => {
|
|
65
|
+
const headers = await asUser({ systemRoles: ["admin"] });
|
|
66
|
+
const created = await api.post<{ data: { id: string } }>(
|
|
67
|
+
"/api/authorization/roles",
|
|
68
|
+
{ key: "auditor", name: "Auditor", grants: [] },
|
|
69
|
+
{ headers },
|
|
70
|
+
);
|
|
71
|
+
expect(created.status).toBe(201);
|
|
72
|
+
const member = await authenticate();
|
|
73
|
+
const assigned = await api.post(
|
|
74
|
+
`/api/authorization/members/${member.userId}/roles`,
|
|
75
|
+
{ roleId: created.body.data.id },
|
|
76
|
+
{ headers },
|
|
77
|
+
);
|
|
78
|
+
expect(assigned.status).toBe(200);
|
|
79
|
+
const read = await api.get<{ data: { roles: { key: string }[] } }>(
|
|
80
|
+
`/api/authorization/members/${member.userId}`,
|
|
81
|
+
{ headers },
|
|
82
|
+
);
|
|
83
|
+
expect(read.body.data.roles.map((r) => r.key)).toEqual(["auditor"]);
|
|
84
|
+
const audit = await api.get<{ data: { changeType: string }[] }>("/api/authorization/audit", {
|
|
85
|
+
headers,
|
|
86
|
+
});
|
|
87
|
+
expect(audit.body.data.map((a) => a.changeType)).toEqual(["member_role_assigned", "role_created"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("denies a permission with an overlay even when a role grants it", async () => {
|
|
91
|
+
const member = await authenticate();
|
|
92
|
+
await applyTestAccess(member.userId, { systemRoles: ["admin"] });
|
|
93
|
+
const admin = await asUser({ systemRoles: ["admin"] });
|
|
94
|
+
const denied = await api.post(
|
|
95
|
+
`/api/authorization/members/${member.userId}/overlays`,
|
|
96
|
+
{ resource: "role", action: "read", effect: "deny" },
|
|
97
|
+
{ headers: admin },
|
|
98
|
+
);
|
|
99
|
+
expect(denied.status).toBe(201);
|
|
100
|
+
const res = await api.get("/api/authorization/roles", { headers: member.headers });
|
|
101
|
+
expect(res.status).toBe(403);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("only removes an overlay for the member in the URL", async () => {
|
|
105
|
+
const admin = await asUser({ systemRoles: ["admin"] });
|
|
106
|
+
const alice = await authenticate();
|
|
107
|
+
const bob = await authenticate();
|
|
108
|
+
const created = await api.post<{ data: { id: string } }>(
|
|
109
|
+
`/api/authorization/members/${alice.userId}/overlays`,
|
|
110
|
+
{ resource: "role", action: "read", effect: "deny" },
|
|
111
|
+
{ headers: admin },
|
|
112
|
+
);
|
|
113
|
+
const wrong = await api.delete(
|
|
114
|
+
`/api/authorization/members/${bob.userId}/overlays/${created.body.data.id}`,
|
|
115
|
+
{ headers: admin },
|
|
116
|
+
);
|
|
117
|
+
expect(wrong.status).toBe(404);
|
|
118
|
+
const member = await api.get<{ data: { overlays: { id: string }[] } }>(
|
|
119
|
+
`/api/authorization/members/${alice.userId}`,
|
|
120
|
+
{ headers: admin },
|
|
121
|
+
);
|
|
122
|
+
expect(member.body.data.overlays.map((o) => o.id)).toEqual([created.body.data.id]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reports the caller's effective permissions", async () => {
|
|
126
|
+
const res = await api.get<{ data: { roles: string[]; grants: { resource: string }[] } }>(
|
|
127
|
+
"/api/authorization/me",
|
|
128
|
+
{ headers: await asUser({ systemRoles: ["admin"] }) },
|
|
129
|
+
);
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
expect(res.body.data.roles).toEqual(["admin"]);
|
|
132
|
+
expect(res.body.data.grants.map((g) => g.resource)).toContain("audit_log");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("never lets an overlay grant authorization itself", async () => {
|
|
136
|
+
const admin = await asUser({ systemRoles: ["admin"] });
|
|
137
|
+
const member = await authenticate();
|
|
138
|
+
const res = await api.post(
|
|
139
|
+
`/api/authorization/members/${member.userId}/overlays`,
|
|
140
|
+
{ resource: "role", action: "update", effect: "grant" },
|
|
141
|
+
{ headers: admin },
|
|
142
|
+
);
|
|
143
|
+
expect(res.status).toBe(400);
|
|
144
|
+
expect(res.body).toMatchObject({ code: "META_GRANT_FORBIDDEN" });
|
|
145
|
+
});
|
|
146
|
+
});
|