@simple-auth-kit/cli 1.5.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/combos/express-drizzle/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/express-drizzle/shared/src/{permission-cache.ts → cache/permission-cache.ts} +25 -9
- package/registry/combos/express-drizzle/shared/src/{rate-limit.store.ts → cache/rate-limit.store.ts} +4 -1
- package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/config}/auth.config.ts +1 -1
- package/registry/combos/express-drizzle/shared/src/{db.ts → config/db.ts} +1 -1
- package/registry/combos/express-drizzle/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/express-drizzle/shared/src/{id.helper.ts → helpers/id.helper.ts} +6 -2
- package/registry/combos/{nestjs-drizzle/shared/src → express-drizzle/shared/src/helpers}/pagination.ts +17 -3
- package/registry/combos/express-drizzle/shared/src/middleware/auth-core-error.middleware.ts +6 -8
- package/registry/combos/express-drizzle/shared/src/middleware/auth.middleware.ts +6 -8
- package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/openapi}/openapi-fragment.ts +13 -4
- package/registry/combos/express-drizzle/shared/src/{openapi-spec.ts → openapi/openapi-spec.ts} +251 -52
- package/registry/combos/express-drizzle/shared/src/repositories/oauth.repository.ts +2 -2
- package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +2 -4
- package/registry/combos/express-drizzle/shared/src/repositories/session.repository.ts +3 -3
- package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/express-drizzle/shared/src/route-tiers.ts +52 -11
- package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +2 -2
- package/registry/combos/express-drizzle/test/bootstrap.ts +4 -2
- package/registry/combos/express-drizzle/test/prove-cycle.ts +1095 -308
- package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +5 -5
- package/registry/combos/express-drizzle/variants/base/src/index.ts +8 -0
- package/registry/combos/express-drizzle/variants/base/src/middleware/authz.middleware.ts +2 -2
- package/registry/combos/express-drizzle/variants/base/src/openapi/openapi-admin.ts +1082 -0
- package/registry/combos/express-drizzle/variants/base/src/rbac.defaults.ts +60 -17
- package/registry/combos/express-drizzle/variants/base/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-drizzle/variants/base/src/repositories/rbac.repository.ts +5 -5
- package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-drizzle/variants/base/src/seed.ts +76 -17
- package/registry/combos/express-drizzle/variants/base/src/services/auth.service.ts +7 -9
- package/registry/combos/express-drizzle/variants/base/test/variant-hooks.ts +167 -45
- package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +5 -5
- package/registry/combos/express-drizzle/variants/workspaces/src/index.ts +8 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/middleware/authz.middleware.ts +3 -3
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi/openapi-admin.ts +1176 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +1 -1
- package/registry/combos/express-drizzle/variants/workspaces/src/rbac.defaults.ts +66 -19
- package/registry/combos/express-drizzle/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-drizzle/variants/workspaces/src/repositories/rbac.repository.ts +5 -5
- package/registry/combos/express-drizzle/variants/workspaces/src/repositories/workspace.repository.ts +9 -11
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +1 -1
- package/registry/combos/express-drizzle/variants/workspaces/src/seed.ts +168 -35
- package/registry/combos/express-drizzle/variants/workspaces/src/services/auth.service.ts +7 -9
- package/registry/combos/express-drizzle/variants/workspaces/test/variant-hooks.ts +539 -133
- package/registry/combos/express-prisma/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/express-prisma/shared/src/{permission-cache.ts → cache/permission-cache.ts} +25 -9
- package/registry/combos/express-prisma/shared/src/{rate-limit.store.ts → cache/rate-limit.store.ts} +4 -1
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/config}/auth.config.ts +1 -1
- package/registry/combos/express-prisma/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/{nestjs-prisma/shared/src → express-prisma/shared/src/helpers}/id.helper.ts +6 -2
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/helpers}/pagination.ts +17 -3
- package/registry/combos/express-prisma/shared/src/middleware/auth-core-error.middleware.ts +6 -8
- package/registry/combos/express-prisma/shared/src/middleware/auth.middleware.ts +6 -8
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/openapi}/openapi-fragment.ts +13 -4
- package/registry/combos/express-prisma/shared/src/{openapi-spec.ts → openapi/openapi-spec.ts} +313 -55
- package/registry/combos/express-prisma/shared/src/repositories/oauth.repository.ts +2 -2
- package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +2 -4
- package/registry/combos/express-prisma/shared/src/repositories/session.repository.ts +2 -2
- package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/express-prisma/shared/src/route-tiers.ts +52 -11
- package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +2 -2
- package/registry/combos/express-prisma/test/bootstrap.ts +7 -3
- package/registry/combos/express-prisma/test/prove-cycle.ts +1095 -308
- package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +6 -6
- package/registry/combos/express-prisma/variants/base/src/index.ts +8 -0
- package/registry/combos/express-prisma/variants/base/src/middleware/authz.middleware.ts +2 -2
- package/registry/combos/express-prisma/variants/base/src/openapi/openapi-admin.ts +1102 -0
- package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +1 -1
- package/registry/combos/express-prisma/variants/base/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-prisma/variants/base/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-prisma/variants/base/src/seed.ts +1 -1
- package/registry/combos/express-prisma/variants/base/src/services/auth.service.ts +6 -8
- package/registry/combos/express-prisma/variants/base/test/variant-hooks.ts +176 -47
- package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +6 -6
- package/registry/combos/express-prisma/variants/workspaces/src/index.ts +8 -0
- package/registry/combos/express-prisma/variants/workspaces/src/middleware/authz.middleware.ts +3 -3
- package/registry/combos/express-prisma/variants/workspaces/src/openapi/openapi-admin.ts +1218 -0
- package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-prisma/variants/workspaces/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/express-prisma/variants/workspaces/src/repositories/workspace.repository.ts +2 -2
- package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/services/auth.service.ts +6 -8
- package/registry/combos/express-prisma/variants/workspaces/test/variant-hooks.ts +538 -134
- package/registry/combos/nestjs-drizzle/shared/src/{guards → ability}/ability.guard.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/nestjs-drizzle/shared/src/{permission-cache.ts → cache/permission-cache.ts} +27 -10
- package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/cache}/rate-limit.store.ts +4 -1
- package/registry/combos/nestjs-drizzle/shared/src/{auth.config.ts → config/auth.config.ts} +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{db.ts → config/db.ts} +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/nestjs-drizzle/shared/src/controllers/auth.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/guards/auth.guard.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{id.helper.ts → helpers/id.helper.ts} +6 -2
- package/registry/combos/{express-prisma/shared/src → nestjs-drizzle/shared/src/helpers}/pagination.ts +17 -3
- package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +2 -2
- package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +8 -12
- package/registry/combos/nestjs-drizzle/shared/src/repositories/session.repository.ts +3 -3
- package/registry/combos/nestjs-drizzle/shared/src/repositories/two-factor.repository.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-drizzle/test/bootstrap.ts +1 -1
- package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-drizzle/variants/base/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/base/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-drizzle/variants/base/src/index.ts +9 -0
- package/registry/combos/nestjs-drizzle/variants/base/src/rbac.defaults.ts +58 -16
- package/registry/combos/nestjs-drizzle/variants/base/src/repositories/audit-log.repository.ts +3 -3
- package/registry/combos/nestjs-drizzle/variants/base/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/nestjs-drizzle/variants/base/src/seed.ts +76 -17
- package/registry/combos/nestjs-drizzle/variants/base/src/services/auth.service.ts +7 -9
- package/registry/combos/nestjs-drizzle/variants/base/test/variant-hooks.ts +167 -45
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/controllers/workspace.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/index.ts +9 -0
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/rbac.defaults.ts +64 -18
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/audit-log.repository.ts +3 -3
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/workspace.repository.ts +8 -10
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/seed.ts +171 -36
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +7 -9
- package/registry/combos/nestjs-drizzle/variants/workspaces/test/variant-hooks.ts +539 -133
- package/registry/combos/nestjs-prisma/shared/src/{guards → ability}/ability.guard.ts +1 -1
- package/registry/combos/nestjs-prisma/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/nestjs-prisma/shared/src/{permission-cache.ts → cache/permission-cache.ts} +27 -10
- package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/cache}/rate-limit.store.ts +4 -1
- package/registry/combos/nestjs-prisma/shared/src/{auth.config.ts → config/auth.config.ts} +1 -1
- package/registry/combos/nestjs-prisma/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/nestjs-prisma/shared/src/controllers/auth.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/shared/src/guards/auth.guard.ts +1 -1
- package/registry/combos/{express-prisma/shared/src → nestjs-prisma/shared/src/helpers}/id.helper.ts +6 -2
- package/registry/combos/nestjs-prisma/shared/src/{pagination.ts → helpers/pagination.ts} +17 -3
- package/registry/combos/nestjs-prisma/shared/src/repositories/oauth.repository.ts +1 -1
- package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +2 -4
- package/registry/combos/nestjs-prisma/shared/src/repositories/session.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-prisma/test/bootstrap.ts +1 -1
- package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-prisma/variants/base/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/gateways/audit-log.gateway.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/index.ts +9 -0
- package/registry/combos/nestjs-prisma/variants/base/src/{docs.ts → openapi/docs.ts} +23 -6
- package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/country.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/customer.repository.ts +6 -5
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/language.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/rbac.repository.ts +3 -3
- package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +6 -8
- package/registry/combos/nestjs-prisma/variants/base/test/variant-hooks.ts +176 -47
- package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-prisma/variants/workspaces/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/controllers/workspace.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/index.ts +9 -0
- package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/country.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/customer.repository.ts +6 -5
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/rbac.repository.ts +3 -3
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/workspace.repository.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +6 -8
- package/registry/combos/nestjs-prisma/variants/workspaces/test/variant-hooks.ts +538 -134
- package/registry/combos/express-drizzle/variants/base/src/openapi-admin.ts +0 -565
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi-admin.ts +0 -596
- package/registry/combos/express-prisma/variants/base/src/openapi-admin.ts +0 -569
- package/registry/combos/express-prisma/variants/workspaces/src/openapi-admin.ts +0 -601
- /package/registry/combos/express-drizzle/shared/src/{http-error.ts → errors/http-error.ts} +0 -0
- /package/registry/combos/express-prisma/shared/src/{http-error.ts → errors/http-error.ts} +0 -0
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
import "dotenv/config";
|
|
6
6
|
import { PrismaPg } from "@prisma/adapter-pg";
|
|
7
7
|
import { PrismaClient } from "../generated/prisma/client.js";
|
|
8
|
-
import { POLICY_VERSION_KEY } from "../src/permission-cache.js";
|
|
8
|
+
import { POLICY_VERSION_KEY } from "../src/cache/permission-cache.js";
|
|
9
9
|
import { permissionCacheStore } from "./bootstrap.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
import {
|
|
11
|
+
renewingToken,
|
|
12
|
+
type AdminSession,
|
|
13
|
+
type Principal,
|
|
14
|
+
type ProofContext,
|
|
15
|
+
type VariantHooks,
|
|
16
|
+
} from "./harness.js";
|
|
17
|
+
|
|
18
|
+
const prisma = new PrismaClient({
|
|
19
|
+
adapter: new PrismaPg({ connectionString: process.env["DATABASE_URL"] }),
|
|
20
|
+
});
|
|
13
21
|
|
|
14
22
|
interface Workspace {
|
|
15
23
|
id: string;
|
|
@@ -22,19 +30,42 @@ async function newAdminWithWorkspace(ctx: ProofContext, label: string) {
|
|
|
22
30
|
const email = ctx.uniqueEmail(label);
|
|
23
31
|
const password = `${label}-pw-12345`;
|
|
24
32
|
const tokens = await ctx.signup(email, password);
|
|
25
|
-
const userId = (
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
const userId = (
|
|
34
|
+
await ctx.call("GET", "/auth/me", { token: tokens.accessToken })
|
|
35
|
+
).body.sub as string;
|
|
36
|
+
const created = await ctx.call("POST", "/workspaces", {
|
|
37
|
+
token: tokens.accessToken,
|
|
38
|
+
body: { name: `${label}-workspace` },
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
email,
|
|
42
|
+
password,
|
|
43
|
+
userId,
|
|
44
|
+
token: tokens.accessToken,
|
|
45
|
+
workspace: created.body as Workspace,
|
|
46
|
+
};
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
export const hooks: VariantHooks = {
|
|
31
50
|
variant: "workspaces",
|
|
32
51
|
|
|
33
|
-
async makeAdmin(
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
async makeAdmin(
|
|
53
|
+
ctx: ProofContext,
|
|
54
|
+
principal: Principal,
|
|
55
|
+
): Promise<AdminSession> {
|
|
56
|
+
const created = await ctx.call("POST", "/workspaces", {
|
|
57
|
+
token: principal.tokens.accessToken,
|
|
58
|
+
body: { name: "proof-workspace" },
|
|
59
|
+
});
|
|
60
|
+
ctx.assert(
|
|
61
|
+
created.status === 201 || created.status === 200,
|
|
62
|
+
`creating a workspace succeeds (got ${created.status})`,
|
|
63
|
+
);
|
|
36
64
|
const workspace = created.body as Workspace;
|
|
37
|
-
ctx.assert(
|
|
65
|
+
ctx.assert(
|
|
66
|
+
workspace.roles.includes("admin"),
|
|
67
|
+
"the creator of a workspace is an admin member of it",
|
|
68
|
+
);
|
|
38
69
|
// No re-login needed: the access token is workspace-agnostic and carries no authorization,
|
|
39
70
|
// so the membership is in effect immediately. freshToken() re-logs in anyway because the
|
|
40
71
|
// 2-second access-token TTL in test/bootstrap.ts can expire a token mid-section.
|
|
@@ -42,15 +73,28 @@ export const hooks: VariantHooks = {
|
|
|
42
73
|
token: principal.tokens.accessToken,
|
|
43
74
|
workspaceId: workspace.id,
|
|
44
75
|
freshToken: renewingToken(async () => {
|
|
45
|
-
const res = await ctx.call("POST", "/auth/login", {
|
|
76
|
+
const res = await ctx.call("POST", "/auth/login", {
|
|
77
|
+
body: { identifier: principal.email, password: principal.password },
|
|
78
|
+
});
|
|
46
79
|
return (res.body as { accessToken: string }).accessToken;
|
|
47
80
|
}),
|
|
48
81
|
};
|
|
49
82
|
},
|
|
50
83
|
|
|
51
|
-
async admitUser(
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
async admitUser(
|
|
85
|
+
ctx: ProofContext,
|
|
86
|
+
admin: AdminSession,
|
|
87
|
+
user: { userId: string; email: string },
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const added = await ctx.call("POST", "/workspaces/members", {
|
|
90
|
+
token: await admin.freshToken(),
|
|
91
|
+
workspaceId: admin.workspaceId,
|
|
92
|
+
body: { email: user.email },
|
|
93
|
+
});
|
|
94
|
+
ctx.assert(
|
|
95
|
+
added.status === 201 || added.status === 200,
|
|
96
|
+
`adding a user to the workspace succeeds (got ${added.status})`,
|
|
97
|
+
);
|
|
54
98
|
},
|
|
55
99
|
|
|
56
100
|
async proveVariantProperties(ctx: ProofContext): Promise<void> {
|
|
@@ -58,115 +102,294 @@ export const hooks: VariantHooks = {
|
|
|
58
102
|
const alpha = await newAdminWithWorkspace(ctx, "alpha");
|
|
59
103
|
const beta = await newAdminWithWorkspace(ctx, "beta");
|
|
60
104
|
|
|
61
|
-
ctx.assert(
|
|
105
|
+
ctx.assert(
|
|
106
|
+
alpha.workspace.id !== beta.workspace.id,
|
|
107
|
+
"two workspaces are distinct",
|
|
108
|
+
);
|
|
62
109
|
|
|
63
110
|
// --- a non-member cannot even see a workspace exists ---
|
|
64
|
-
const outsider = await ctx.call("GET", "/auth/admin/users", {
|
|
65
|
-
|
|
111
|
+
const outsider = await ctx.call("GET", "/auth/admin/users", {
|
|
112
|
+
token: alpha.token,
|
|
113
|
+
workspaceId: beta.workspace.id,
|
|
114
|
+
});
|
|
115
|
+
ctx.assert(
|
|
116
|
+
outsider.status === 403,
|
|
117
|
+
`an admin of one workspace is refused in a workspace they are not a member of (got ${outsider.status})`,
|
|
118
|
+
);
|
|
66
119
|
|
|
67
|
-
const bogus = await ctx.call("GET", "/auth/admin/users", {
|
|
68
|
-
|
|
120
|
+
const bogus = await ctx.call("GET", "/auth/admin/users", {
|
|
121
|
+
token: alpha.token,
|
|
122
|
+
workspaceId: "00000000-0000-0000-0000-000000000000",
|
|
123
|
+
});
|
|
124
|
+
ctx.assert(
|
|
125
|
+
bogus.status === 403,
|
|
126
|
+
`an unknown workspace id is refused the same way as someone else's (got ${bogus.status})`,
|
|
127
|
+
);
|
|
69
128
|
|
|
70
|
-
const noHeader = await ctx.call("GET", "/auth/admin/users", {
|
|
71
|
-
|
|
129
|
+
const noHeader = await ctx.call("GET", "/auth/admin/users", {
|
|
130
|
+
token: alpha.token,
|
|
131
|
+
});
|
|
132
|
+
ctx.assert(
|
|
133
|
+
noHeader.status === 403,
|
|
134
|
+
`a workspace-scoped route refuses a request that names no workspace (got ${noHeader.status})`,
|
|
135
|
+
);
|
|
72
136
|
|
|
73
137
|
// --- alpha joins beta as a plain member: their admin role in alpha must not follow them ---
|
|
74
|
-
const added = await ctx.call("POST", "/workspaces/members", {
|
|
75
|
-
|
|
138
|
+
const added = await ctx.call("POST", "/workspaces/members", {
|
|
139
|
+
token: beta.token,
|
|
140
|
+
workspaceId: beta.workspace.id,
|
|
141
|
+
body: { email: alpha.email },
|
|
142
|
+
});
|
|
143
|
+
ctx.assert(
|
|
144
|
+
added.status === 201 || added.status === 200,
|
|
145
|
+
`an admin can add an existing user to their workspace (got ${added.status})`,
|
|
146
|
+
);
|
|
76
147
|
const alphaInBeta = added.body as { memberId: string; roles: string[] };
|
|
77
|
-
ctx.assert(
|
|
148
|
+
ctx.assert(
|
|
149
|
+
alphaInBeta.roles.join() === "member",
|
|
150
|
+
`a new member defaults to the member role (got ${alphaInBeta.roles.join()})`,
|
|
151
|
+
);
|
|
78
152
|
|
|
79
|
-
const meInAlpha = await ctx.call("GET", "/auth/me", {
|
|
80
|
-
|
|
153
|
+
const meInAlpha = await ctx.call("GET", "/auth/me", {
|
|
154
|
+
token: alpha.token,
|
|
155
|
+
workspaceId: alpha.workspace.id,
|
|
156
|
+
});
|
|
157
|
+
ctx.assert(
|
|
158
|
+
meInAlpha.body.roles.includes("admin"),
|
|
159
|
+
"the same token is admin in its own workspace",
|
|
160
|
+
);
|
|
81
161
|
|
|
82
|
-
const meInBeta = await ctx.call("GET", "/auth/me", {
|
|
83
|
-
|
|
84
|
-
|
|
162
|
+
const meInBeta = await ctx.call("GET", "/auth/me", {
|
|
163
|
+
token: alpha.token,
|
|
164
|
+
workspaceId: beta.workspace.id,
|
|
165
|
+
});
|
|
166
|
+
ctx.assert(
|
|
167
|
+
!meInBeta.body.roles.includes("admin"),
|
|
168
|
+
"THE core property: the admin role held in workspace A is absent in workspace B",
|
|
169
|
+
);
|
|
170
|
+
ctx.assert(
|
|
171
|
+
meInBeta.body.roles.includes("member"),
|
|
172
|
+
"…and only the roles granted by workspace B's membership apply there",
|
|
173
|
+
);
|
|
85
174
|
|
|
86
|
-
const adminAttemptInBeta = await ctx.call("GET", "/auth/admin/users", {
|
|
87
|
-
|
|
175
|
+
const adminAttemptInBeta = await ctx.call("GET", "/auth/admin/users", {
|
|
176
|
+
token: alpha.token,
|
|
177
|
+
workspaceId: beta.workspace.id,
|
|
178
|
+
});
|
|
179
|
+
ctx.assert(
|
|
180
|
+
adminAttemptInBeta.status === 403,
|
|
181
|
+
`being an admin elsewhere does not unlock workspace B's admin endpoints (got ${adminAttemptInBeta.status})`,
|
|
182
|
+
);
|
|
88
183
|
|
|
89
184
|
// --- roles are per workspace: the same role name can mean different things ---
|
|
90
|
-
const roleInBeta = await ctx.call("POST", "/auth/admin/roles", {
|
|
91
|
-
|
|
92
|
-
|
|
185
|
+
const roleInBeta = await ctx.call("POST", "/auth/admin/roles", {
|
|
186
|
+
token: beta.token,
|
|
187
|
+
workspaceId: beta.workspace.id,
|
|
188
|
+
body: { slug: "shared-name" },
|
|
189
|
+
});
|
|
190
|
+
const roleInAlpha = await ctx.call("POST", "/auth/admin/roles", {
|
|
191
|
+
token: alpha.token,
|
|
192
|
+
workspaceId: alpha.workspace.id,
|
|
193
|
+
body: { slug: "shared-name" },
|
|
194
|
+
});
|
|
195
|
+
ctx.assert(
|
|
196
|
+
roleInBeta.status === 201 && roleInAlpha.status === 201,
|
|
197
|
+
"the same role name can exist independently in two workspaces",
|
|
198
|
+
);
|
|
93
199
|
|
|
94
|
-
await ctx.call(
|
|
200
|
+
await ctx.call(
|
|
201
|
+
"POST",
|
|
202
|
+
`/auth/admin/roles/${(roleInBeta.body as { id: string }).id}/permissions`,
|
|
203
|
+
{
|
|
204
|
+
token: beta.token,
|
|
205
|
+
workspaceId: beta.workspace.id,
|
|
206
|
+
body: { permission: "beta:only" },
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
await ctx.call("POST", `/auth/admin/users/${alpha.userId}/roles`, {
|
|
95
210
|
token: beta.token,
|
|
96
211
|
workspaceId: beta.workspace.id,
|
|
97
|
-
body: {
|
|
212
|
+
body: { role: "shared-name" },
|
|
98
213
|
});
|
|
99
|
-
await ctx.call("POST", `/auth/admin/users/${alpha.userId}/roles`, { token: beta.token, workspaceId: beta.workspace.id, body: { role: "shared-name" } });
|
|
100
214
|
|
|
101
|
-
const permsInBeta = await ctx.call("GET", "/auth/me", {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
215
|
+
const permsInBeta = await ctx.call("GET", "/auth/me", {
|
|
216
|
+
token: alpha.token,
|
|
217
|
+
workspaceId: beta.workspace.id,
|
|
218
|
+
});
|
|
219
|
+
ctx.assert(
|
|
220
|
+
permsInBeta.body.permissions.includes("beta:only"),
|
|
221
|
+
"a role assigned in workspace B grants its permissions in workspace B",
|
|
222
|
+
);
|
|
223
|
+
const permsInAlpha = await ctx.call("GET", "/auth/me", {
|
|
224
|
+
token: alpha.token,
|
|
225
|
+
workspaceId: alpha.workspace.id,
|
|
226
|
+
});
|
|
227
|
+
ctx.assert(
|
|
228
|
+
!permsInAlpha.body.permissions.includes("beta:only"),
|
|
229
|
+
"…and the identically-named role in workspace A does not carry them",
|
|
230
|
+
);
|
|
105
231
|
|
|
106
232
|
// --- direct grants attach to the membership, not the user ---
|
|
107
|
-
const directGrant = await ctx.call(
|
|
108
|
-
|
|
233
|
+
const directGrant = await ctx.call(
|
|
234
|
+
"POST",
|
|
235
|
+
`/auth/admin/users/${alpha.userId}/permissions`,
|
|
236
|
+
{
|
|
237
|
+
token: beta.token,
|
|
238
|
+
workspaceId: beta.workspace.id,
|
|
239
|
+
body: { permission: "beta:direct" },
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
ctx.assert(
|
|
243
|
+
directGrant.status === 201 || directGrant.status === 200,
|
|
244
|
+
`a direct grant inside workspace B succeeds (got ${directGrant.status})`,
|
|
245
|
+
);
|
|
246
|
+
const afterGrantBeta = await ctx.call("GET", "/auth/me", {
|
|
247
|
+
token: alpha.token,
|
|
109
248
|
workspaceId: beta.workspace.id,
|
|
110
|
-
body: { permission: "beta:direct" },
|
|
111
249
|
});
|
|
112
|
-
ctx.assert(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
ctx.
|
|
250
|
+
ctx.assert(
|
|
251
|
+
afterGrantBeta.body.permissions.includes("beta:direct"),
|
|
252
|
+
"the direct grant applies in workspace B",
|
|
253
|
+
);
|
|
254
|
+
const afterGrantAlpha = await ctx.call("GET", "/auth/me", {
|
|
255
|
+
token: alpha.token,
|
|
256
|
+
workspaceId: alpha.workspace.id,
|
|
257
|
+
});
|
|
258
|
+
ctx.assert(
|
|
259
|
+
!afterGrantAlpha.body.permissions.includes("beta:direct"),
|
|
260
|
+
"the direct grant does not leak into workspace A",
|
|
261
|
+
);
|
|
117
262
|
|
|
118
263
|
// --- an admin cannot act on a user who is not in their workspace ---
|
|
119
|
-
const crossBlock = await ctx.call(
|
|
120
|
-
|
|
264
|
+
const crossBlock = await ctx.call(
|
|
265
|
+
"POST",
|
|
266
|
+
`/auth/admin/users/${beta.userId}/block`,
|
|
267
|
+
{ token: alpha.token, workspaceId: alpha.workspace.id },
|
|
268
|
+
);
|
|
269
|
+
ctx.assert(
|
|
270
|
+
crossBlock.status === 404,
|
|
271
|
+
`an admin cannot block a user who is not a member of their workspace (got ${crossBlock.status})`,
|
|
272
|
+
);
|
|
121
273
|
|
|
122
274
|
// --- audit log is scoped too ---
|
|
123
|
-
const betaAudit = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
124
|
-
|
|
275
|
+
const betaAudit = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
276
|
+
token: beta.token,
|
|
277
|
+
workspaceId: beta.workspace.id,
|
|
278
|
+
});
|
|
125
279
|
ctx.assert(
|
|
126
|
-
|
|
280
|
+
betaAudit.status === 200,
|
|
281
|
+
`an admin can read their workspace's audit log (got ${betaAudit.status})`,
|
|
282
|
+
);
|
|
283
|
+
ctx.assert(
|
|
284
|
+
(betaAudit.body.items as Array<{ workspaceId: string | null }>).every(
|
|
285
|
+
(e) => e.workspaceId === beta.workspace.id,
|
|
286
|
+
),
|
|
127
287
|
"the audit log never returns another workspace's entries",
|
|
128
288
|
);
|
|
129
289
|
|
|
130
290
|
// --- membership management ---
|
|
131
|
-
const myWorkspaces = await ctx.call("GET", "/workspaces", {
|
|
132
|
-
|
|
291
|
+
const myWorkspaces = await ctx.call("GET", "/workspaces", {
|
|
292
|
+
token: alpha.token,
|
|
293
|
+
});
|
|
294
|
+
ctx.assert(
|
|
295
|
+
myWorkspaces.status === 200,
|
|
296
|
+
`a user can list the workspaces they belong to (got ${myWorkspaces.status})`,
|
|
297
|
+
);
|
|
133
298
|
const ids = (myWorkspaces.body as Workspace[]).map((w) => w.id);
|
|
134
|
-
ctx.assert(
|
|
299
|
+
ctx.assert(
|
|
300
|
+
ids.includes(alpha.workspace.id) && ids.includes(beta.workspace.id),
|
|
301
|
+
"the list covers every workspace the user is a member of",
|
|
302
|
+
);
|
|
135
303
|
|
|
136
|
-
const promote = await ctx.call(
|
|
137
|
-
|
|
304
|
+
const promote = await ctx.call(
|
|
305
|
+
"PUT",
|
|
306
|
+
`/workspaces/members/${alphaInBeta.memberId}/roles`,
|
|
307
|
+
{
|
|
308
|
+
token: beta.token,
|
|
309
|
+
workspaceId: beta.workspace.id,
|
|
310
|
+
body: { roles: ["member", "shared-name"] },
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
ctx.assert(
|
|
314
|
+
promote.status === 200,
|
|
315
|
+
`an admin can set a member's roles (got ${promote.status})`,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const memberList = await ctx.call("GET", "/workspaces/members", {
|
|
319
|
+
token: alpha.token,
|
|
138
320
|
workspaceId: beta.workspace.id,
|
|
139
|
-
body: { roles: ["member", "shared-name"] },
|
|
140
321
|
});
|
|
141
|
-
ctx.assert(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
322
|
+
ctx.assert(
|
|
323
|
+
memberList.status === 200 && memberList.body.length === 2,
|
|
324
|
+
`any member can list the workspace's members (got ${memberList.status})`,
|
|
325
|
+
);
|
|
145
326
|
|
|
146
|
-
const removed = await ctx.call(
|
|
147
|
-
|
|
327
|
+
const removed = await ctx.call(
|
|
328
|
+
"DELETE",
|
|
329
|
+
`/workspaces/members/${alphaInBeta.memberId}`,
|
|
330
|
+
{ token: beta.token, workspaceId: beta.workspace.id },
|
|
331
|
+
);
|
|
332
|
+
ctx.assert(
|
|
333
|
+
removed.status === 200,
|
|
334
|
+
`an admin can remove a member (got ${removed.status})`,
|
|
335
|
+
);
|
|
148
336
|
|
|
149
|
-
const afterRemoval = await ctx.call("GET", "/auth/me", {
|
|
150
|
-
|
|
337
|
+
const afterRemoval = await ctx.call("GET", "/auth/me", {
|
|
338
|
+
token: alpha.token,
|
|
339
|
+
workspaceId: beta.workspace.id,
|
|
340
|
+
});
|
|
341
|
+
ctx.assert(
|
|
342
|
+
afterRemoval.status === 403,
|
|
343
|
+
`a removed member loses access to the workspace immediately (got ${afterRemoval.status})`,
|
|
344
|
+
);
|
|
151
345
|
|
|
152
|
-
const stillInAlpha = await ctx.call("GET", "/auth/me", {
|
|
153
|
-
|
|
346
|
+
const stillInAlpha = await ctx.call("GET", "/auth/me", {
|
|
347
|
+
token: alpha.token,
|
|
348
|
+
workspaceId: alpha.workspace.id,
|
|
349
|
+
});
|
|
350
|
+
ctx.assert(
|
|
351
|
+
stillInAlpha.status === 200 && stillInAlpha.body.roles.includes("admin"),
|
|
352
|
+
"…while their own workspace is untouched — the same login still works",
|
|
353
|
+
);
|
|
154
354
|
|
|
155
355
|
// --- revocation is immediate here: the context is resolved per request, not per token ---
|
|
156
|
-
const immediate = await ctx.call(
|
|
356
|
+
const immediate = await ctx.call(
|
|
357
|
+
"POST",
|
|
358
|
+
`/auth/admin/users/${beta.userId}/permissions`,
|
|
359
|
+
{
|
|
360
|
+
token: beta.token,
|
|
361
|
+
workspaceId: beta.workspace.id,
|
|
362
|
+
body: { permission: "probe:immediate" },
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
ctx.assert(
|
|
366
|
+
immediate.status === 201 || immediate.status === 200,
|
|
367
|
+
`a direct grant to oneself succeeds (got ${immediate.status})`,
|
|
368
|
+
);
|
|
369
|
+
const seen = await ctx.call("GET", "/auth/me", {
|
|
157
370
|
token: beta.token,
|
|
158
371
|
workspaceId: beta.workspace.id,
|
|
159
|
-
body: { permission: "probe:immediate" },
|
|
160
372
|
});
|
|
161
|
-
ctx.assert(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
373
|
+
ctx.assert(
|
|
374
|
+
seen.body.permissions.includes("probe:immediate"),
|
|
375
|
+
"the grant is visible on the very next request, with no re-login",
|
|
376
|
+
);
|
|
377
|
+
await ctx.call(
|
|
378
|
+
"POST",
|
|
379
|
+
`/auth/admin/users/${beta.userId}/permissions/${encodeURIComponent("probe:immediate")}/revoke`,
|
|
380
|
+
{
|
|
381
|
+
token: beta.token,
|
|
382
|
+
workspaceId: beta.workspace.id,
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
const gone = await ctx.call("GET", "/auth/me", {
|
|
165
386
|
token: beta.token,
|
|
166
387
|
workspaceId: beta.workspace.id,
|
|
167
388
|
});
|
|
168
|
-
|
|
169
|
-
|
|
389
|
+
ctx.assert(
|
|
390
|
+
!gone.body.permissions.includes("probe:immediate"),
|
|
391
|
+
"…and the revocation lands on the very next request too, on the same token",
|
|
392
|
+
);
|
|
170
393
|
|
|
171
394
|
await proveNewWorkspaceIsProvisioned(ctx);
|
|
172
395
|
await proveWorkspaceHeaderCannotBeSkipped(ctx, alpha);
|
|
@@ -179,58 +402,145 @@ export const hooks: VariantHooks = {
|
|
|
179
402
|
// transaction, a brand-new workspace has no `Role` rows, and its creator's ["admin", "member"]
|
|
180
403
|
// would be names with nothing behind them — locked out with no way back in. Walks the entire
|
|
181
404
|
// administrative surface of a brand-new workspace as the person who just created it.
|
|
182
|
-
async function proveNewWorkspaceIsProvisioned(
|
|
405
|
+
async function proveNewWorkspaceIsProvisioned(
|
|
406
|
+
ctx: ProofContext,
|
|
407
|
+
): Promise<void> {
|
|
183
408
|
const creator = await newAdminWithWorkspace(ctx, "fresh");
|
|
184
409
|
const ws = creator.workspace.id;
|
|
185
410
|
const as = { token: creator.token, workspaceId: ws };
|
|
186
|
-
ctx.assert(
|
|
411
|
+
ctx.assert(
|
|
412
|
+
creator.workspace.roles.includes("admin"),
|
|
413
|
+
"the creator of a brand-new workspace holds the admin role in it",
|
|
414
|
+
);
|
|
187
415
|
|
|
188
416
|
const guestEmail = ctx.uniqueEmail("fresh-guest");
|
|
189
417
|
const guestTokens = await ctx.signup(guestEmail, "fresh-guest-pw-1");
|
|
190
|
-
const guestId = (
|
|
418
|
+
const guestId = (
|
|
419
|
+
await ctx.call("GET", "/auth/me", { token: guestTokens.accessToken })
|
|
420
|
+
).body.sub as string;
|
|
191
421
|
|
|
192
|
-
const ok = (res: { status: number }) =>
|
|
422
|
+
const ok = (res: { status: number }) =>
|
|
423
|
+
res.status === 200 || res.status === 201;
|
|
193
424
|
|
|
194
|
-
const added = await ctx.call("POST", "/workspaces/members", {
|
|
195
|
-
|
|
425
|
+
const added = await ctx.call("POST", "/workspaces/members", {
|
|
426
|
+
...as,
|
|
427
|
+
body: { email: guestEmail },
|
|
428
|
+
});
|
|
429
|
+
ctx.assert(
|
|
430
|
+
ok(added),
|
|
431
|
+
`members:manage — the creator can add a member (got ${added.status})`,
|
|
432
|
+
);
|
|
196
433
|
const memberId = (added.body as { memberId: string }).memberId;
|
|
197
434
|
|
|
198
|
-
ctx.assert(
|
|
199
|
-
|
|
435
|
+
ctx.assert(
|
|
436
|
+
ok(await ctx.call("GET", "/auth/admin/users", as)),
|
|
437
|
+
"users:read — the creator can list the members",
|
|
438
|
+
);
|
|
439
|
+
ctx.assert(
|
|
440
|
+
ok(await ctx.call("GET", "/auth/admin/audit-log", as)),
|
|
441
|
+
"audit-log:read — the creator can read the audit log",
|
|
442
|
+
);
|
|
200
443
|
|
|
201
|
-
const role = await ctx.call("POST", "/auth/admin/roles", {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
444
|
+
const role = await ctx.call("POST", "/auth/admin/roles", {
|
|
445
|
+
...as,
|
|
446
|
+
body: { slug: "fresh-role" },
|
|
447
|
+
});
|
|
448
|
+
ctx.assert(
|
|
449
|
+
ok(role),
|
|
450
|
+
`roles:manage — the creator can define a role (got ${role.status})`,
|
|
451
|
+
);
|
|
452
|
+
const attach = await ctx.call(
|
|
453
|
+
"POST",
|
|
454
|
+
`/auth/admin/roles/${(role.body as { id: string }).id}/permissions`,
|
|
455
|
+
{ ...as, body: { permission: "fresh:probe" } },
|
|
456
|
+
);
|
|
457
|
+
ctx.assert(
|
|
458
|
+
ok(attach),
|
|
459
|
+
`roles:manage — the creator can say what that role carries (got ${attach.status})`,
|
|
460
|
+
);
|
|
205
461
|
|
|
206
|
-
ctx.assert(
|
|
207
|
-
|
|
462
|
+
ctx.assert(
|
|
463
|
+
ok(
|
|
464
|
+
await ctx.call("POST", `/auth/admin/users/${guestId}/roles`, {
|
|
465
|
+
...as,
|
|
466
|
+
body: { role: "fresh-role" },
|
|
467
|
+
}),
|
|
468
|
+
),
|
|
469
|
+
"roles:assign — assign a role",
|
|
470
|
+
);
|
|
471
|
+
ctx.assert(
|
|
472
|
+
ok(
|
|
473
|
+
await ctx.call(
|
|
474
|
+
"POST",
|
|
475
|
+
`/auth/admin/users/${guestId}/roles/fresh-role/revoke`,
|
|
476
|
+
as,
|
|
477
|
+
),
|
|
478
|
+
),
|
|
479
|
+
"roles:assign — revoke a role",
|
|
480
|
+
);
|
|
208
481
|
|
|
209
482
|
ctx.assert(
|
|
210
|
-
ok(
|
|
483
|
+
ok(
|
|
484
|
+
await ctx.call("POST", `/auth/admin/users/${guestId}/permissions`, {
|
|
485
|
+
...as,
|
|
486
|
+
body: { permission: "fresh:direct" },
|
|
487
|
+
}),
|
|
488
|
+
),
|
|
211
489
|
"permissions:grant — grant directly",
|
|
212
490
|
);
|
|
213
491
|
ctx.assert(
|
|
214
|
-
ok(
|
|
492
|
+
ok(
|
|
493
|
+
await ctx.call(
|
|
494
|
+
"POST",
|
|
495
|
+
`/auth/admin/users/${guestId}/permissions/${encodeURIComponent("fresh:direct")}/revoke`,
|
|
496
|
+
as,
|
|
497
|
+
),
|
|
498
|
+
),
|
|
215
499
|
"permissions:grant — revoke a direct grant",
|
|
216
500
|
);
|
|
217
501
|
|
|
218
|
-
ctx.assert(
|
|
219
|
-
|
|
502
|
+
ctx.assert(
|
|
503
|
+
ok(await ctx.call("POST", `/auth/admin/users/${guestId}/block`, as)),
|
|
504
|
+
"users:block — block a member",
|
|
505
|
+
);
|
|
506
|
+
ctx.assert(
|
|
507
|
+
ok(await ctx.call("POST", `/auth/admin/users/${guestId}/unblock`, as)),
|
|
508
|
+
"users:block — unblock a member",
|
|
509
|
+
);
|
|
220
510
|
|
|
221
|
-
ctx.assert(
|
|
222
|
-
|
|
511
|
+
ctx.assert(
|
|
512
|
+
ok(
|
|
513
|
+
await ctx.call("PUT", `/workspaces/members/${memberId}/roles`, {
|
|
514
|
+
...as,
|
|
515
|
+
body: { roles: ["member"] },
|
|
516
|
+
}),
|
|
517
|
+
),
|
|
518
|
+
"roles:assign — set a member's roles",
|
|
519
|
+
);
|
|
520
|
+
ctx.assert(
|
|
521
|
+
ok(await ctx.call("DELETE", `/workspaces/members/${memberId}`, as)),
|
|
522
|
+
"members:manage — remove a member",
|
|
523
|
+
);
|
|
223
524
|
|
|
224
525
|
// A second workspace's "admin" is a different row that happens to share a name.
|
|
225
526
|
const other = await newAdminWithWorkspace(ctx, "fresh-other");
|
|
226
|
-
const trespass = await ctx.call("GET", "/auth/admin/users", {
|
|
227
|
-
|
|
527
|
+
const trespass = await ctx.call("GET", "/auth/admin/users", {
|
|
528
|
+
token: creator.token,
|
|
529
|
+
workspaceId: other.workspace.id,
|
|
530
|
+
});
|
|
531
|
+
ctx.assert(
|
|
532
|
+
trespass.status === 403,
|
|
533
|
+
`being provisioned as admin of one new workspace grants nothing in another (got ${trespass.status})`,
|
|
534
|
+
);
|
|
228
535
|
}
|
|
229
536
|
|
|
230
537
|
// `X-Workspace-Id` is the only thing that says which workspace a request acts in, so a missing
|
|
231
538
|
// or junk header must never reach a handler with no workspace resolved — and must deny the same
|
|
232
539
|
// way a workspace you aren't a member of does, so a caller can't tell the two apart.
|
|
233
|
-
async function proveWorkspaceHeaderCannotBeSkipped(
|
|
540
|
+
async function proveWorkspaceHeaderCannotBeSkipped(
|
|
541
|
+
ctx: ProofContext,
|
|
542
|
+
member: { token: string; workspace: Workspace },
|
|
543
|
+
): Promise<void> {
|
|
234
544
|
const cases: Array<[string, Record<string, string>]> = [
|
|
235
545
|
["absent", {}],
|
|
236
546
|
["empty", { "x-workspace-id": "" }],
|
|
@@ -241,41 +551,77 @@ async function proveWorkspaceHeaderCannotBeSkipped(ctx: ProofContext, member: {
|
|
|
241
551
|
];
|
|
242
552
|
|
|
243
553
|
for (const [label, headers] of cases) {
|
|
244
|
-
const admin = await ctx.call("GET", "/auth/admin/users", {
|
|
245
|
-
|
|
554
|
+
const admin = await ctx.call("GET", "/auth/admin/users", {
|
|
555
|
+
token: member.token,
|
|
556
|
+
headers,
|
|
557
|
+
});
|
|
558
|
+
ctx.assert(
|
|
559
|
+
admin.status === 403,
|
|
560
|
+
`a permission-gated admin route denies a request whose workspace id is ${label} (got ${admin.status})`,
|
|
561
|
+
);
|
|
246
562
|
|
|
247
|
-
const members = await ctx.call("GET", "/workspaces/members", {
|
|
248
|
-
|
|
563
|
+
const members = await ctx.call("GET", "/workspaces/members", {
|
|
564
|
+
token: member.token,
|
|
565
|
+
headers,
|
|
566
|
+
});
|
|
567
|
+
ctx.assert(
|
|
568
|
+
members.status === 403,
|
|
569
|
+
`a member-gated workspace route denies a request whose workspace id is ${label} (got ${members.status})`,
|
|
570
|
+
);
|
|
249
571
|
}
|
|
250
572
|
|
|
251
573
|
// An id that exists but isn't yours and one that exists nowhere must answer identically, body
|
|
252
574
|
// included, or the 403 becomes a workspace oracle.
|
|
253
575
|
const stranger = await newAdminWithWorkspace(ctx, "stranger");
|
|
254
|
-
const notYours = await ctx.call("GET", "/auth/admin/users", {
|
|
255
|
-
|
|
576
|
+
const notYours = await ctx.call("GET", "/auth/admin/users", {
|
|
577
|
+
token: member.token,
|
|
578
|
+
workspaceId: stranger.workspace.id,
|
|
579
|
+
});
|
|
580
|
+
const notReal = await ctx.call("GET", "/auth/admin/users", {
|
|
581
|
+
token: member.token,
|
|
582
|
+
workspaceId: "00000000-0000-0000-0000-000000000000",
|
|
583
|
+
});
|
|
256
584
|
ctx.assert(
|
|
257
|
-
notYours.status === notReal.status &&
|
|
585
|
+
notYours.status === notReal.status &&
|
|
586
|
+
notYours.body?.message === notReal.body?.message,
|
|
258
587
|
`someone else's workspace and a nonexistent one answer identically (${notYours.status}/${notYours.body?.message} vs ${notReal.status}/${notReal.body?.message})`,
|
|
259
588
|
);
|
|
260
589
|
|
|
261
590
|
// /auth/me works either way, since clients call it before they know which workspaces exist —
|
|
262
591
|
// it must answer with an empty context, never one borrowed from a workspace the user belongs to.
|
|
263
592
|
const meNoHeader = await ctx.call("GET", "/auth/me", { token: member.token });
|
|
264
|
-
ctx.assert(meNoHeader.status === 200, `GET /auth/me answers without a workspace header (got ${meNoHeader.status})`);
|
|
265
593
|
ctx.assert(
|
|
266
|
-
|
|
594
|
+
meNoHeader.status === 200,
|
|
595
|
+
`GET /auth/me answers without a workspace header (got ${meNoHeader.status})`,
|
|
596
|
+
);
|
|
597
|
+
ctx.assert(
|
|
598
|
+
Array.isArray(meNoHeader.body?.roles) &&
|
|
599
|
+
meNoHeader.body.roles.length === 0 &&
|
|
600
|
+
meNoHeader.body.permissions.length === 0,
|
|
267
601
|
`…with empty roles and permissions rather than some workspace's (got roles=${JSON.stringify(meNoHeader.body?.roles)})`,
|
|
268
602
|
);
|
|
269
|
-
ctx.assert(
|
|
603
|
+
ctx.assert(
|
|
604
|
+
meNoHeader.body?.sub && meNoHeader.body?.sessionId,
|
|
605
|
+
"…while still identifying the user, which is what makes it callable before any workspace is chosen",
|
|
606
|
+
);
|
|
270
607
|
|
|
271
|
-
const meEmptyHeader = await ctx.call("GET", "/auth/me", {
|
|
608
|
+
const meEmptyHeader = await ctx.call("GET", "/auth/me", {
|
|
609
|
+
token: member.token,
|
|
610
|
+
headers: { "x-workspace-id": "" },
|
|
611
|
+
});
|
|
272
612
|
ctx.assert(
|
|
273
613
|
meEmptyHeader.status === 200 && meEmptyHeader.body.roles.length === 0,
|
|
274
614
|
`an empty workspace header is treated as naming no workspace, not as naming one (got ${meEmptyHeader.status})`,
|
|
275
615
|
);
|
|
276
616
|
|
|
277
|
-
const meBogusHeader = await ctx.call("GET", "/auth/me", {
|
|
278
|
-
|
|
617
|
+
const meBogusHeader = await ctx.call("GET", "/auth/me", {
|
|
618
|
+
token: member.token,
|
|
619
|
+
workspaceId: "00000000-0000-0000-0000-000000000000",
|
|
620
|
+
});
|
|
621
|
+
ctx.assert(
|
|
622
|
+
meBogusHeader.status === 403,
|
|
623
|
+
`but a workspace id that is named and not yours is refused outright, even here (got ${meBogusHeader.status})`,
|
|
624
|
+
);
|
|
279
625
|
}
|
|
280
626
|
|
|
281
627
|
// The permission cache is keyed on the membership, not the user, so invalidating one workspace
|
|
@@ -287,9 +633,16 @@ async function proveTheCacheIsScopedAndReal(
|
|
|
287
633
|
beta: { token: string; userId: string; workspace: Workspace },
|
|
288
634
|
): Promise<void> {
|
|
289
635
|
const N = 8;
|
|
290
|
-
await ctx.call("GET", "/auth/me", {
|
|
636
|
+
await ctx.call("GET", "/auth/me", {
|
|
637
|
+
token: alpha.token,
|
|
638
|
+
workspaceId: alpha.workspace.id,
|
|
639
|
+
}); // warm
|
|
291
640
|
const before = { ...permissionCacheStore.stats };
|
|
292
|
-
for (let i = 0; i < N; i += 1)
|
|
641
|
+
for (let i = 0; i < N; i += 1)
|
|
642
|
+
await ctx.call("GET", "/auth/me", {
|
|
643
|
+
token: alpha.token,
|
|
644
|
+
workspaceId: alpha.workspace.id,
|
|
645
|
+
});
|
|
293
646
|
const after = permissionCacheStore.stats;
|
|
294
647
|
ctx.assert(
|
|
295
648
|
after.misses === before.misses && after.hits === before.hits + N,
|
|
@@ -298,36 +651,87 @@ async function proveTheCacheIsScopedAndReal(
|
|
|
298
651
|
|
|
299
652
|
// A grant inside beta must invalidate beta's entry and leave alpha's alone — the cache
|
|
300
653
|
// subject is the (user, workspace) pair.
|
|
301
|
-
await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions`, {
|
|
654
|
+
await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions`, {
|
|
655
|
+
token: beta.token,
|
|
656
|
+
workspaceId: beta.workspace.id,
|
|
657
|
+
body: { permission: "probe:cache" },
|
|
658
|
+
});
|
|
302
659
|
const beforeAlpha = { ...permissionCacheStore.stats };
|
|
303
|
-
const alphaAfter = await ctx.call("GET", "/auth/me", {
|
|
660
|
+
const alphaAfter = await ctx.call("GET", "/auth/me", {
|
|
661
|
+
token: alpha.token,
|
|
662
|
+
workspaceId: alpha.workspace.id,
|
|
663
|
+
});
|
|
304
664
|
ctx.assert(
|
|
305
|
-
permissionCacheStore.stats.misses === beforeAlpha.misses &&
|
|
665
|
+
permissionCacheStore.stats.misses === beforeAlpha.misses &&
|
|
666
|
+
alphaAfter.status === 200,
|
|
306
667
|
"a grant made in workspace B does not invalidate the caller's cached membership of workspace A",
|
|
307
668
|
);
|
|
308
|
-
const betaAfter = await ctx.call("GET", "/auth/me", {
|
|
309
|
-
|
|
310
|
-
|
|
669
|
+
const betaAfter = await ctx.call("GET", "/auth/me", {
|
|
670
|
+
token: beta.token,
|
|
671
|
+
workspaceId: beta.workspace.id,
|
|
672
|
+
});
|
|
673
|
+
ctx.assert(
|
|
674
|
+
(betaAfter.body?.permissions ?? []).includes("probe:cache"),
|
|
675
|
+
"…while workspace B sees the grant on its very next request",
|
|
676
|
+
);
|
|
677
|
+
await ctx.call(
|
|
678
|
+
"POST",
|
|
679
|
+
`/auth/admin/users/${beta.userId}/permissions/${encodeURIComponent("probe:cache")}/revoke`,
|
|
680
|
+
{
|
|
681
|
+
token: beta.token,
|
|
682
|
+
workspaceId: beta.workspace.id,
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
const betaRevoked = await ctx.call("GET", "/auth/me", {
|
|
311
686
|
token: beta.token,
|
|
312
687
|
workspaceId: beta.workspace.id,
|
|
313
688
|
});
|
|
314
|
-
|
|
315
|
-
|
|
689
|
+
ctx.assert(
|
|
690
|
+
!(betaRevoked.body?.permissions ?? []).includes("probe:cache"),
|
|
691
|
+
"…and loses it again on the request after the revoke",
|
|
692
|
+
);
|
|
316
693
|
}
|
|
317
694
|
|
|
318
695
|
// Writes the row directly rather than through the admin API, ruling out any in-memory side
|
|
319
696
|
// channel — a `psql` session would look exactly like this, bump included (permission-cache.ts).
|
|
320
|
-
async function proveARawDatabaseEditChangesEnforcement(
|
|
321
|
-
|
|
322
|
-
|
|
697
|
+
async function proveARawDatabaseEditChangesEnforcement(
|
|
698
|
+
ctx: ProofContext,
|
|
699
|
+
beta: { token: string; workspace: Workspace },
|
|
700
|
+
): Promise<void> {
|
|
701
|
+
const opened = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
702
|
+
token: beta.token,
|
|
703
|
+
workspaceId: beta.workspace.id,
|
|
704
|
+
});
|
|
705
|
+
ctx.assert(
|
|
706
|
+
opened.status === 200,
|
|
707
|
+
`the workspace admin can read the audit log to begin with (got ${opened.status})`,
|
|
708
|
+
);
|
|
323
709
|
|
|
324
|
-
await prisma.permission.update({
|
|
710
|
+
await prisma.permission.update({
|
|
711
|
+
where: { slug: "audit-log:read" },
|
|
712
|
+
data: { isActive: false },
|
|
713
|
+
});
|
|
325
714
|
await permissionCacheStore.bump(POLICY_VERSION_KEY);
|
|
326
|
-
const denied = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
327
|
-
|
|
715
|
+
const denied = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
716
|
+
token: beta.token,
|
|
717
|
+
workspaceId: beta.workspace.id,
|
|
718
|
+
});
|
|
719
|
+
ctx.assert(
|
|
720
|
+
denied.status === 403,
|
|
721
|
+
`a permission deactivated by a raw database write stops opening its route (got ${denied.status})`,
|
|
722
|
+
);
|
|
328
723
|
|
|
329
|
-
await prisma.permission.update({
|
|
724
|
+
await prisma.permission.update({
|
|
725
|
+
where: { slug: "audit-log:read" },
|
|
726
|
+
data: { isActive: true },
|
|
727
|
+
});
|
|
330
728
|
await permissionCacheStore.bump(POLICY_VERSION_KEY);
|
|
331
|
-
const restored = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
332
|
-
|
|
729
|
+
const restored = await ctx.call("GET", "/auth/admin/audit-log", {
|
|
730
|
+
token: beta.token,
|
|
731
|
+
workspaceId: beta.workspace.id,
|
|
732
|
+
});
|
|
733
|
+
ctx.assert(
|
|
734
|
+
restored.status === 200,
|
|
735
|
+
`…and reactivating it in the database opens the route again (got ${restored.status})`,
|
|
736
|
+
);
|
|
333
737
|
}
|