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