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