@smartcrab/contracts-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/api-keys.d.ts +251 -0
- package/dist/api-keys.js +78 -0
- package/dist/audit-events.d.ts +94 -0
- package/dist/audit-events.js +45 -0
- package/dist/billing.d.ts +30 -0
- package/dist/billing.js +30 -0
- package/dist/clients.d.ts +328 -0
- package/dist/clients.js +141 -0
- package/dist/common.d.ts +95 -0
- package/dist/common.js +99 -0
- package/dist/connections.d.ts +201 -0
- package/dist/connections.js +107 -0
- package/dist/domains.d.ts +167 -0
- package/dist/domains.js +74 -0
- package/dist/environments.d.ts +194 -0
- package/dist/environments.js +101 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/primitives.d.ts +49 -0
- package/dist/primitives.js +56 -0
- package/dist/projects.d.ts +78 -0
- package/dist/projects.js +49 -0
- package/dist/rbac.d.ts +222 -0
- package/dist/rbac.js +226 -0
- package/dist/usage.d.ts +72 -0
- package/dist/usage.js +52 -0
- package/dist/user-jobs.d.ts +253 -0
- package/dist/user-jobs.js +95 -0
- package/dist/user-subresources.d.ts +161 -0
- package/dist/user-subresources.js +85 -0
- package/dist/users.d.ts +193 -0
- package/dist/users.js +112 -0
- package/dist/webhooks.d.ts +306 -0
- package/dist/webhooks.js +134 -0
- package/dist/workspaces.d.ts +172 -0
- package/dist/workspaces.js +77 -0
- package/package.json +42 -0
- package/src/api-keys.test.ts +86 -0
- package/src/api-keys.ts +97 -0
- package/src/audit-events.test.ts +44 -0
- package/src/audit-events.ts +57 -0
- package/src/billing.test.ts +57 -0
- package/src/billing.ts +46 -0
- package/src/clients.test.ts +169 -0
- package/src/clients.ts +182 -0
- package/src/common.test.ts +102 -0
- package/src/common.ts +127 -0
- package/src/connections.test.ts +65 -0
- package/src/connections.ts +134 -0
- package/src/domains.test.ts +48 -0
- package/src/domains.ts +104 -0
- package/src/environments.test.ts +67 -0
- package/src/environments.ts +133 -0
- package/src/index.ts +27 -0
- package/src/primitives.ts +100 -0
- package/src/projects.test.ts +49 -0
- package/src/projects.ts +72 -0
- package/src/rbac.test.ts +148 -0
- package/src/rbac.ts +250 -0
- package/src/secret-fields.test.ts +84 -0
- package/src/usage.test.ts +57 -0
- package/src/usage.ts +66 -0
- package/src/user-jobs.test.ts +86 -0
- package/src/user-jobs.ts +120 -0
- package/src/user-subresources.test.ts +115 -0
- package/src/user-subresources.ts +128 -0
- package/src/users.test.ts +91 -0
- package/src/users.ts +149 -0
- package/src/webhooks.test.ts +187 -0
- package/src/webhooks.ts +182 -0
- package/src/workspaces.test.ts +127 -0
- package/src/workspaces.ts +106 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @smartcrab/contracts-management — zod request/response contracts for
|
|
3
|
+
* the Management API (design.md §26), consumed by control-worker routes,
|
|
4
|
+
* `@smartcrab/management-client`, the dashboard SPA, and the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `@smartcrab/contracts-public`'s conventions: a local
|
|
7
|
+
* dependency-free style (no `@smartcrab/result`), `idPattern`-based id
|
|
8
|
+
* schemas, `z.strictObject` on every response shape, and per-field doc
|
|
9
|
+
* comments citing design.md sections.
|
|
10
|
+
*/
|
|
11
|
+
export * from "./common.js";
|
|
12
|
+
export * from "./primitives.js";
|
|
13
|
+
export * from "./rbac.js";
|
|
14
|
+
export * from "./api-keys.js";
|
|
15
|
+
export * from "./workspaces.js";
|
|
16
|
+
export * from "./projects.js";
|
|
17
|
+
export * from "./environments.js";
|
|
18
|
+
export * from "./clients.js";
|
|
19
|
+
export * from "./connections.js";
|
|
20
|
+
export * from "./domains.js";
|
|
21
|
+
export * from "./users.js";
|
|
22
|
+
export * from "./user-subresources.js";
|
|
23
|
+
export * from "./user-jobs.js";
|
|
24
|
+
export * from "./webhooks.js";
|
|
25
|
+
export * from "./audit-events.js";
|
|
26
|
+
export * from "./usage.js";
|
|
27
|
+
export * from "./billing.js";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared primitives for the Management API (design.md §26).
|
|
5
|
+
*
|
|
6
|
+
* ID schemas for kinds already modeled by `@smartcrab/contracts-public`
|
|
7
|
+
* (client/user/session/passkey/identity/webhook) are re-exported from there
|
|
8
|
+
* rather than redefined, per the assignment notes: this package's
|
|
9
|
+
* `package.json` only declares `zod` and `@smartcrab/contracts-public` as
|
|
10
|
+
* dependencies, so `@smartcrab/identifiers` and `@smartcrab/tenant-context`
|
|
11
|
+
* are not resolvable here directly (pnpm's strict `node_modules` only links a
|
|
12
|
+
* package's own declared dependencies, not its transitive ones). For the
|
|
13
|
+
* remaining ID kinds this Management API surface needs
|
|
14
|
+
* (workspace/project/environment/connection/domain/job/management-api-key/
|
|
15
|
+
* audit-event) that `contracts-public` does not export, the exact
|
|
16
|
+
* `<prefix>_<32 base32url chars>` rule and prefix table are mirrored locally
|
|
17
|
+
* from `@smartcrab/identifiers`'s `ID_PREFIXES` (design.md §7.5) so the
|
|
18
|
+
* pattern never drifts from the canonical source.
|
|
19
|
+
*/
|
|
20
|
+
export {
|
|
21
|
+
clientIdSchema,
|
|
22
|
+
httpsUrlSchema,
|
|
23
|
+
identityIdSchema,
|
|
24
|
+
isoDateTimeSchema,
|
|
25
|
+
passkeyIdSchema,
|
|
26
|
+
sessionIdSchema,
|
|
27
|
+
userIdSchema,
|
|
28
|
+
webhookIdSchema,
|
|
29
|
+
} from "@smartcrab/contracts-public";
|
|
30
|
+
export type {
|
|
31
|
+
ClientId,
|
|
32
|
+
IdentityId,
|
|
33
|
+
PasskeyId,
|
|
34
|
+
SessionId,
|
|
35
|
+
UserId,
|
|
36
|
+
WebhookId,
|
|
37
|
+
} from "@smartcrab/contracts-public";
|
|
38
|
+
|
|
39
|
+
/** Mirrors `@smartcrab/identifiers`'s `ID_PREFIXES` (design.md §7.5) for kinds not re-exported above. */
|
|
40
|
+
const LOCAL_ID_PREFIXES = {
|
|
41
|
+
workspace: "wsp",
|
|
42
|
+
project: "prj",
|
|
43
|
+
environment: "env",
|
|
44
|
+
connection: "con",
|
|
45
|
+
domain: "dom",
|
|
46
|
+
job: "job",
|
|
47
|
+
apiKey: "mky",
|
|
48
|
+
event: "evt",
|
|
49
|
+
delivery: "dlv",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
type LocalIdKind = keyof typeof LOCAL_ID_PREFIXES;
|
|
53
|
+
|
|
54
|
+
/** Zod-ready regex, identical shape to `@smartcrab/identifiers`'s `idPattern`. */
|
|
55
|
+
const localIdPattern = (kind: LocalIdKind): RegExp =>
|
|
56
|
+
new RegExp(`^${LOCAL_ID_PREFIXES[kind]}_[a-z2-7]{32}$`);
|
|
57
|
+
|
|
58
|
+
export const workspaceIdSchema = z.string().regex(localIdPattern("workspace"));
|
|
59
|
+
export type WorkspaceId = z.infer<typeof workspaceIdSchema>;
|
|
60
|
+
|
|
61
|
+
export const projectIdSchema = z.string().regex(localIdPattern("project"));
|
|
62
|
+
export type ProjectId = z.infer<typeof projectIdSchema>;
|
|
63
|
+
|
|
64
|
+
export const environmentIdSchema = z.string().regex(localIdPattern("environment"));
|
|
65
|
+
export type EnvironmentId = z.infer<typeof environmentIdSchema>;
|
|
66
|
+
|
|
67
|
+
export const connectionIdSchema = z.string().regex(localIdPattern("connection"));
|
|
68
|
+
export type ConnectionId = z.infer<typeof connectionIdSchema>;
|
|
69
|
+
|
|
70
|
+
export const domainIdSchema = z.string().regex(localIdPattern("domain"));
|
|
71
|
+
export type DomainId = z.infer<typeof domainIdSchema>;
|
|
72
|
+
|
|
73
|
+
export const jobIdSchema = z.string().regex(localIdPattern("job"));
|
|
74
|
+
export type JobId = z.infer<typeof jobIdSchema>;
|
|
75
|
+
|
|
76
|
+
export const apiKeyIdSchema = z.string().regex(localIdPattern("apiKey"));
|
|
77
|
+
export type ApiKeyId = z.infer<typeof apiKeyIdSchema>;
|
|
78
|
+
|
|
79
|
+
export const auditEventIdSchema = z.string().regex(localIdPattern("event"));
|
|
80
|
+
export type AuditEventId = z.infer<typeof auditEventIdSchema>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generic domain event ID (`evt_...`, design.md §7.5) — the exact same ID
|
|
84
|
+
* kind as `auditEventIdSchema` above, exported under a resource-neutral name
|
|
85
|
+
* for consumers (e.g. `webhooks.ts`'s delivery history `event_id`) that
|
|
86
|
+
* reference a domain event without being about the audit log itself.
|
|
87
|
+
*/
|
|
88
|
+
export const eventIdSchema = z.string().regex(localIdPattern("event"));
|
|
89
|
+
export type EventId = z.infer<typeof eventIdSchema>;
|
|
90
|
+
|
|
91
|
+
/** `webhook_delivery_history.id` (design.md §29.4, §10.10): `dlv_...`. */
|
|
92
|
+
export const deliveryIdSchema = z.string().regex(localIdPattern("delivery"));
|
|
93
|
+
export type DeliveryId = z.infer<typeof deliveryIdSchema>;
|
|
94
|
+
|
|
95
|
+
/** `workspaces.slug` / `projects.slug` (design.md §11.1, §11.3): URL-safe, lowercase, hyphenated. */
|
|
96
|
+
export const slugSchema = z
|
|
97
|
+
.string()
|
|
98
|
+
.min(1)
|
|
99
|
+
.max(63)
|
|
100
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { projectCreateRequestSchema, projectListQuerySchema, projectSchema } from "./projects.js";
|
|
3
|
+
|
|
4
|
+
const VALID_PROJECT = {
|
|
5
|
+
id: `prj_${"a".repeat(32)}`,
|
|
6
|
+
workspace_id: `wsp_${"a".repeat(32)}`,
|
|
7
|
+
name: "Storefront",
|
|
8
|
+
slug: "storefront",
|
|
9
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
10
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
11
|
+
deleted_at: null,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("projectSchema (design.md §11.3)", () => {
|
|
15
|
+
test("accepts a valid project", () => {
|
|
16
|
+
expect(projectSchema.safeParse(VALID_PROJECT).success).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects an unknown top-level key", () => {
|
|
20
|
+
expect(projectSchema.safeParse({ ...VALID_PROJECT, internal_note: "x" }).success).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rejects a malformed id", () => {
|
|
24
|
+
expect(projectSchema.safeParse({ ...VALID_PROJECT, id: "not-a-project-id" }).success).toBe(
|
|
25
|
+
false,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("projectCreateRequestSchema", () => {
|
|
31
|
+
test("requires workspace_id and name; slug is optional", () => {
|
|
32
|
+
expect(
|
|
33
|
+
projectCreateRequestSchema.safeParse({
|
|
34
|
+
workspace_id: VALID_PROJECT.workspace_id,
|
|
35
|
+
name: "Storefront",
|
|
36
|
+
}).success,
|
|
37
|
+
).toBe(true);
|
|
38
|
+
expect(projectCreateRequestSchema.safeParse({ name: "Storefront" }).success).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("projectListQuerySchema (pagination, design.md §26.3)", () => {
|
|
43
|
+
test("rejects a page size above the max of 100", () => {
|
|
44
|
+
expect(
|
|
45
|
+
projectListQuerySchema.safeParse({ workspace_id: VALID_PROJECT.workspace_id, limit: 250 })
|
|
46
|
+
.success,
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
3
|
+
import { projectIdSchema, slugSchema, workspaceIdSchema } from "./primitives.js";
|
|
4
|
+
|
|
5
|
+
/** `projects` (design.md §11.3, §7.2). No secret/internal-routing columns. */
|
|
6
|
+
export const projectSchema = z.strictObject({
|
|
7
|
+
id: projectIdSchema,
|
|
8
|
+
workspace_id: workspaceIdSchema,
|
|
9
|
+
name: z.string().min(1).max(200),
|
|
10
|
+
slug: slugSchema,
|
|
11
|
+
created_at: z.iso.datetime(),
|
|
12
|
+
updated_at: z.iso.datetime(),
|
|
13
|
+
deleted_at: z.iso.datetime().nullable(),
|
|
14
|
+
});
|
|
15
|
+
export type Project = z.infer<typeof projectSchema>;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// GET /projects
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** `/projects` is a flat top-level route (design.md §26.2); `workspace_id` scopes the listing. */
|
|
22
|
+
export const projectListQuerySchema = paginationQuerySchema.extend({
|
|
23
|
+
workspace_id: workspaceIdSchema,
|
|
24
|
+
});
|
|
25
|
+
export type ProjectListQuery = z.infer<typeof projectListQuerySchema>;
|
|
26
|
+
|
|
27
|
+
export const projectListResponseSchema = cursorPageSchema(projectSchema);
|
|
28
|
+
export type ProjectListResponse = z.infer<typeof projectListResponseSchema>;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// POST /projects
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export const projectCreateRequestSchema = z.strictObject({
|
|
35
|
+
workspace_id: workspaceIdSchema,
|
|
36
|
+
name: z.string().min(1).max(200),
|
|
37
|
+
slug: slugSchema.optional(),
|
|
38
|
+
});
|
|
39
|
+
export type ProjectCreateRequest = z.infer<typeof projectCreateRequestSchema>;
|
|
40
|
+
|
|
41
|
+
export const projectCreateResponseSchema = projectSchema;
|
|
42
|
+
export type ProjectCreateResponse = z.infer<typeof projectCreateResponseSchema>;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// GET /projects/:projectId
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export const projectGetResponseSchema = projectSchema;
|
|
49
|
+
export type ProjectGetResponse = z.infer<typeof projectGetResponseSchema>;
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// PATCH /projects/:projectId
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const projectUpdateRequestSchema = z.strictObject({
|
|
56
|
+
name: z.string().min(1).max(200).optional(),
|
|
57
|
+
slug: slugSchema.optional(),
|
|
58
|
+
});
|
|
59
|
+
export type ProjectUpdateRequest = z.infer<typeof projectUpdateRequestSchema>;
|
|
60
|
+
|
|
61
|
+
export const projectUpdateResponseSchema = projectSchema;
|
|
62
|
+
export type ProjectUpdateResponse = z.infer<typeof projectUpdateResponseSchema>;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// DELETE /projects/:projectId (design.md §11.3 `deleted_at` — logical delete)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export const projectDeleteResponseSchema = z.strictObject({
|
|
69
|
+
id: projectIdSchema,
|
|
70
|
+
deleted: z.literal(true),
|
|
71
|
+
});
|
|
72
|
+
export type ProjectDeleteResponse = z.infer<typeof projectDeleteResponseSchema>;
|
package/src/rbac.test.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CREDENTIAL_MUTATING_SCOPES,
|
|
4
|
+
IMPERSONATION_MAX_DURATION_MINUTES,
|
|
5
|
+
MANAGEMENT_SCOPES,
|
|
6
|
+
ROLE_SCOPES,
|
|
7
|
+
WORKSPACE_ROLES,
|
|
8
|
+
impersonationCreateRequestSchema,
|
|
9
|
+
isReadOnlyScope,
|
|
10
|
+
roleHasScope,
|
|
11
|
+
scopesForRole,
|
|
12
|
+
workspaceRoleSchema,
|
|
13
|
+
} from "./rbac.js";
|
|
14
|
+
|
|
15
|
+
const USER_ID = `usr_${"a".repeat(32)}`;
|
|
16
|
+
|
|
17
|
+
describe("WORKSPACE_ROLES (design.md §27.2 / §11.2)", () => {
|
|
18
|
+
test("matches the exact six roles from the design", () => {
|
|
19
|
+
expect(WORKSPACE_ROLES).toEqual([
|
|
20
|
+
"owner",
|
|
21
|
+
"admin",
|
|
22
|
+
"developer",
|
|
23
|
+
"support",
|
|
24
|
+
"billing",
|
|
25
|
+
"viewer",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("workspaceRoleSchema rejects an unknown role", () => {
|
|
30
|
+
expect(workspaceRoleSchema.safeParse("superadmin").success).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("scopesForRole / roleHasScope", () => {
|
|
35
|
+
test("scopesForRole returns exactly ROLE_SCOPES[role]", () => {
|
|
36
|
+
for (const role of WORKSPACE_ROLES) {
|
|
37
|
+
expect(scopesForRole(role)).toBe(ROLE_SCOPES[role]);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("roleHasScope reflects membership in ROLE_SCOPES", () => {
|
|
42
|
+
expect(roleHasScope("owner", "billing:write")).toBe(true);
|
|
43
|
+
expect(roleHasScope("viewer", "billing:write")).toBe(false);
|
|
44
|
+
expect(roleHasScope("viewer", "billing:read")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("design.md §27.2 role table invariants", () => {
|
|
49
|
+
test("owner's scopes are a superset of every other role's scopes", () => {
|
|
50
|
+
const ownerScopes = new Set(ROLE_SCOPES.owner);
|
|
51
|
+
for (const role of WORKSPACE_ROLES) {
|
|
52
|
+
if (role === "owner") continue;
|
|
53
|
+
for (const scope of ROLE_SCOPES[role]) {
|
|
54
|
+
expect(ownerScopes.has(scope)).toBe(true);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("owner is granted every scope that exists", () => {
|
|
60
|
+
expect(new Set(ROLE_SCOPES.owner)).toEqual(new Set(MANAGEMENT_SCOPES));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("viewer is read-only: every one of its scopes is a `:read` scope", () => {
|
|
64
|
+
expect(ROLE_SCOPES.viewer.length).toBeGreaterThan(0);
|
|
65
|
+
for (const scope of ROLE_SCOPES.viewer) {
|
|
66
|
+
expect(isReadOnlyScope(scope)).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("support lacks every credential-mutating scope (design.md §25)", () => {
|
|
71
|
+
for (const scope of CREDENTIAL_MUTATING_SCOPES) {
|
|
72
|
+
expect(ROLE_SCOPES.support.includes(scope)).toBe(false);
|
|
73
|
+
}
|
|
74
|
+
// §27.2: support's only write capability is session revocation.
|
|
75
|
+
expect(roleHasScope("support", "user_sessions:write")).toBe(true);
|
|
76
|
+
expect(roleHasScope("support", "users:write")).toBe(false);
|
|
77
|
+
expect(roleHasScope("support", "users:block")).toBe(false);
|
|
78
|
+
expect(roleHasScope("support", "users:delete")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("developer has integration resource scopes and token introspection (design.md §27.2)", () => {
|
|
82
|
+
const allowedResources = ["clients", "connections", "domains", "webhooks"];
|
|
83
|
+
expect(ROLE_SCOPES.developer.length).toBeGreaterThan(0);
|
|
84
|
+
expect(ROLE_SCOPES.developer).toContain("introspect");
|
|
85
|
+
for (const scope of ROLE_SCOPES.developer) {
|
|
86
|
+
if (scope === "introspect") continue;
|
|
87
|
+
const resource = scope.split(":")[0] ?? "";
|
|
88
|
+
expect(allowedResources).toContain(resource);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("introspect is a read-only scope for owner/admin/developer", () => {
|
|
93
|
+
expect(MANAGEMENT_SCOPES).toContain("introspect");
|
|
94
|
+
expect(isReadOnlyScope("introspect")).toBe(true);
|
|
95
|
+
for (const role of ["owner", "admin", "developer"] as const) {
|
|
96
|
+
expect(roleHasScope(role, "introspect")).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
for (const role of ["support", "billing", "viewer"] as const) {
|
|
99
|
+
expect(roleHasScope(role, "introspect")).toBe(false);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("workspace_members:read is granted to owner/admin/support/billing/viewer but withheld from developer", () => {
|
|
104
|
+
expect(roleHasScope("owner", "workspace_members:read")).toBe(true);
|
|
105
|
+
expect(roleHasScope("admin", "workspace_members:read")).toBe(true);
|
|
106
|
+
expect(roleHasScope("support", "workspace_members:read")).toBe(true);
|
|
107
|
+
expect(roleHasScope("billing", "workspace_members:read")).toBe(true);
|
|
108
|
+
expect(roleHasScope("viewer", "workspace_members:read")).toBe(true);
|
|
109
|
+
expect(roleHasScope("developer", "workspace_members:read")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("impersonation (design.md §27.4)", () => {
|
|
114
|
+
const base = { user_id: USER_ID, reason: "Investigating a customer-reported login bug." };
|
|
115
|
+
|
|
116
|
+
test("accepts a valid request within the 15-minute cap", () => {
|
|
117
|
+
expect(
|
|
118
|
+
impersonationCreateRequestSchema.safeParse({
|
|
119
|
+
...base,
|
|
120
|
+
duration_minutes: IMPERSONATION_MAX_DURATION_MINUTES,
|
|
121
|
+
}).success,
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects a duration over 15 minutes", () => {
|
|
126
|
+
expect(
|
|
127
|
+
impersonationCreateRequestSchema.safeParse({
|
|
128
|
+
...base,
|
|
129
|
+
duration_minutes: IMPERSONATION_MAX_DURATION_MINUTES + 1,
|
|
130
|
+
}).success,
|
|
131
|
+
).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("rejects a missing reason", () => {
|
|
135
|
+
const { reason: _reason, ...withoutReason } = base;
|
|
136
|
+
expect(
|
|
137
|
+
impersonationCreateRequestSchema.safeParse({ ...withoutReason, duration_minutes: 10 })
|
|
138
|
+
.success,
|
|
139
|
+
).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("rejects a too-short reason", () => {
|
|
143
|
+
expect(
|
|
144
|
+
impersonationCreateRequestSchema.safeParse({ ...base, reason: "why", duration_minutes: 10 })
|
|
145
|
+
.success,
|
|
146
|
+
).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/rbac.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { userIdSchema } from "./primitives.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dashboard / Management API RBAC (design.md §27.2's role table, §11.2
|
|
6
|
+
* `workspace_members.role`).
|
|
7
|
+
*/
|
|
8
|
+
export const WORKSPACE_ROLES = [
|
|
9
|
+
"owner",
|
|
10
|
+
"admin",
|
|
11
|
+
"developer",
|
|
12
|
+
"support",
|
|
13
|
+
"billing",
|
|
14
|
+
"viewer",
|
|
15
|
+
] as const;
|
|
16
|
+
export const workspaceRoleSchema = z.enum(WORKSPACE_ROLES);
|
|
17
|
+
export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number];
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Scopes (design.md §26.1: "scope最小化" — Management API keys are granted a
|
|
21
|
+
// minimal explicit scope list, not a raw role)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The full set of grantable Management API scopes. Most use the
|
|
26
|
+
* `<resource>:<action>` form; protocol capabilities such as `introspect` are
|
|
27
|
+
* standalone. This is the declarative permission vocabulary both Management
|
|
28
|
+
* API keys (design.md §11.12) and dashboard roles (design.md §27.2) use.
|
|
29
|
+
*/
|
|
30
|
+
export const MANAGEMENT_SCOPES = [
|
|
31
|
+
"workspaces:read",
|
|
32
|
+
"workspaces:write",
|
|
33
|
+
// `GET /workspaces/:workspaceId/members` (design.md §11.2, §27.3's
|
|
34
|
+
// Security settings screen, §27.2's RBAC table). See the `ROLE_SCOPES`
|
|
35
|
+
// doc comment below for which roles are granted this.
|
|
36
|
+
"workspace_members:read",
|
|
37
|
+
"projects:read",
|
|
38
|
+
"projects:write",
|
|
39
|
+
"environments:read",
|
|
40
|
+
"environments:write",
|
|
41
|
+
"clients:read",
|
|
42
|
+
"clients:write",
|
|
43
|
+
"clients:rotate_secret",
|
|
44
|
+
"connections:read",
|
|
45
|
+
"connections:write",
|
|
46
|
+
"domains:read",
|
|
47
|
+
"domains:write",
|
|
48
|
+
"users:read",
|
|
49
|
+
"users:write",
|
|
50
|
+
"users:block",
|
|
51
|
+
"users:delete",
|
|
52
|
+
"user_identities:read",
|
|
53
|
+
"user_identities:write",
|
|
54
|
+
"user_passkeys:read",
|
|
55
|
+
"user_passkeys:write",
|
|
56
|
+
"user_sessions:read",
|
|
57
|
+
"user_sessions:write",
|
|
58
|
+
"user_data_jobs:read",
|
|
59
|
+
"user_data_jobs:write",
|
|
60
|
+
"webhooks:read",
|
|
61
|
+
"webhooks:write",
|
|
62
|
+
"webhooks:rotate_secret",
|
|
63
|
+
"webhooks:test",
|
|
64
|
+
"audit_events:read",
|
|
65
|
+
"usage:read",
|
|
66
|
+
// Scoped Management API key permission for POST /oauth2/introspect
|
|
67
|
+
// (design.md §15.8); not a resource CRUD scope.
|
|
68
|
+
"introspect",
|
|
69
|
+
"billing:read",
|
|
70
|
+
"billing:write",
|
|
71
|
+
"api_keys:read",
|
|
72
|
+
"api_keys:write",
|
|
73
|
+
"impersonation:create",
|
|
74
|
+
] as const;
|
|
75
|
+
export const managementScopeSchema = z.enum(MANAGEMENT_SCOPES);
|
|
76
|
+
export type ManagementScope = (typeof MANAGEMENT_SCOPES)[number];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Scopes that let an actor change how a user authenticates: linking/unlinking
|
|
80
|
+
* a social identity or adding/removing a passkey (design.md §12.3/§12.5,
|
|
81
|
+
* §25's "credential"). Deliberately narrower than "anything about the user
|
|
82
|
+
* account" — e.g. blocking a user or editing their display name is an
|
|
83
|
+
* account-state/profile change, not a credential change.
|
|
84
|
+
*
|
|
85
|
+
* design.md §25: "support roleだけではcredentialを変更できない." §27.4 forbids
|
|
86
|
+
* the same category of action during impersonation ("credential変更...は禁止").
|
|
87
|
+
*/
|
|
88
|
+
export const CREDENTIAL_MUTATING_SCOPES = [
|
|
89
|
+
"user_identities:write",
|
|
90
|
+
"user_passkeys:write",
|
|
91
|
+
] as const satisfies readonly ManagementScope[];
|
|
92
|
+
|
|
93
|
+
/** design.md §27.4: impersonation additionally forbids billing and API key operations. */
|
|
94
|
+
export const IMPERSONATION_FORBIDDEN_SCOPES = [
|
|
95
|
+
...CREDENTIAL_MUTATING_SCOPES,
|
|
96
|
+
"billing:read",
|
|
97
|
+
"billing:write",
|
|
98
|
+
"api_keys:read",
|
|
99
|
+
"api_keys:write",
|
|
100
|
+
] as const satisfies readonly ManagementScope[];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* design.md §27.2 role → permission table:
|
|
104
|
+
*
|
|
105
|
+
* | Role | 権限 |
|
|
106
|
+
* |-----------|------|
|
|
107
|
+
* | Owner | 全操作、課金、Workspace削除 |
|
|
108
|
+
* | Admin | Project、Environment、ユーザー管理 |
|
|
109
|
+
* | Developer | Client、Connection、Domain、Webhook |
|
|
110
|
+
* | Support | ユーザー閲覧、session失効 |
|
|
111
|
+
* | Billing | 課金・使用量 |
|
|
112
|
+
* | Viewer | 読み取りのみ |
|
|
113
|
+
*
|
|
114
|
+
* `workspace_members:read` grant decision (no dedicated row in the table
|
|
115
|
+
* above — the assignment that added `GET /workspaces/:workspaceId/members`
|
|
116
|
+
* requires a defensible reading of it): granted to owner/admin/support/
|
|
117
|
+
* billing/viewer, withheld from developer. The Developer table lists "Client,
|
|
118
|
+
* Connection, Domain, Webhook" resources, with `introspect` as a standalone
|
|
119
|
+
* OAuth capability for resource-server token validation. The test below
|
|
120
|
+
* ensures the developer role gets no other resource prefixes. Every other
|
|
121
|
+
* role either reads broadly (`viewer`, "読み取りのみ") or needs the roster for
|
|
122
|
+
* its work (`admin` administers the workspace; `support`/`billing` deal with
|
|
123
|
+
* the workspace's people and billing contacts day to day).
|
|
124
|
+
*/
|
|
125
|
+
export const ROLE_SCOPES: Readonly<Record<WorkspaceRole, readonly ManagementScope[]>> = {
|
|
126
|
+
owner: MANAGEMENT_SCOPES,
|
|
127
|
+
admin: [
|
|
128
|
+
"workspaces:read",
|
|
129
|
+
"workspace_members:read",
|
|
130
|
+
"projects:read",
|
|
131
|
+
"projects:write",
|
|
132
|
+
"environments:read",
|
|
133
|
+
"environments:write",
|
|
134
|
+
"introspect",
|
|
135
|
+
"users:read",
|
|
136
|
+
"users:write",
|
|
137
|
+
"users:block",
|
|
138
|
+
"users:delete",
|
|
139
|
+
"user_identities:read",
|
|
140
|
+
"user_identities:write",
|
|
141
|
+
"user_passkeys:read",
|
|
142
|
+
"user_passkeys:write",
|
|
143
|
+
"user_sessions:read",
|
|
144
|
+
"user_sessions:write",
|
|
145
|
+
"user_data_jobs:read",
|
|
146
|
+
"user_data_jobs:write",
|
|
147
|
+
"audit_events:read",
|
|
148
|
+
"impersonation:create",
|
|
149
|
+
],
|
|
150
|
+
developer: [
|
|
151
|
+
"clients:read",
|
|
152
|
+
"introspect",
|
|
153
|
+
"clients:write",
|
|
154
|
+
"clients:rotate_secret",
|
|
155
|
+
"connections:read",
|
|
156
|
+
"connections:write",
|
|
157
|
+
"domains:read",
|
|
158
|
+
"domains:write",
|
|
159
|
+
"webhooks:read",
|
|
160
|
+
"webhooks:write",
|
|
161
|
+
"webhooks:rotate_secret",
|
|
162
|
+
"webhooks:test",
|
|
163
|
+
],
|
|
164
|
+
support: [
|
|
165
|
+
"users:read",
|
|
166
|
+
"user_identities:read",
|
|
167
|
+
"user_passkeys:read",
|
|
168
|
+
"user_sessions:read",
|
|
169
|
+
"user_sessions:write",
|
|
170
|
+
"workspace_members:read",
|
|
171
|
+
],
|
|
172
|
+
billing: [
|
|
173
|
+
"billing:read",
|
|
174
|
+
"billing:write",
|
|
175
|
+
"usage:read",
|
|
176
|
+
"workspaces:read",
|
|
177
|
+
"workspace_members:read",
|
|
178
|
+
],
|
|
179
|
+
viewer: [
|
|
180
|
+
"workspaces:read",
|
|
181
|
+
"workspace_members:read",
|
|
182
|
+
"projects:read",
|
|
183
|
+
"environments:read",
|
|
184
|
+
"clients:read",
|
|
185
|
+
"connections:read",
|
|
186
|
+
"domains:read",
|
|
187
|
+
"users:read",
|
|
188
|
+
"user_identities:read",
|
|
189
|
+
"user_passkeys:read",
|
|
190
|
+
"user_sessions:read",
|
|
191
|
+
"user_data_jobs:read",
|
|
192
|
+
"webhooks:read",
|
|
193
|
+
"audit_events:read",
|
|
194
|
+
"usage:read",
|
|
195
|
+
"billing:read",
|
|
196
|
+
"api_keys:read",
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const scopesForRole = (role: WorkspaceRole): readonly ManagementScope[] => ROLE_SCOPES[role];
|
|
201
|
+
|
|
202
|
+
export const roleHasScope = (role: WorkspaceRole, scope: ManagementScope): boolean =>
|
|
203
|
+
ROLE_SCOPES[role].includes(scope);
|
|
204
|
+
|
|
205
|
+
/** A scope is read-only when it grants inspection without changing a resource or token state. */
|
|
206
|
+
export const isReadOnlyScope = (scope: ManagementScope): boolean =>
|
|
207
|
+
scope === "introspect" || scope.endsWith(":read");
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Impersonation (design.md §27.4)
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/** design.md §27.4: "OwnerまたはAdmin" may start an impersonation session. */
|
|
214
|
+
export const IMPERSONATION_ALLOWED_ROLES = [
|
|
215
|
+
"owner",
|
|
216
|
+
"admin",
|
|
217
|
+
] as const satisfies readonly WorkspaceRole[];
|
|
218
|
+
export const impersonationAllowedRoleSchema = z.enum(IMPERSONATION_ALLOWED_ROLES);
|
|
219
|
+
export type ImpersonationAllowedRole = (typeof IMPERSONATION_ALLOWED_ROLES)[number];
|
|
220
|
+
|
|
221
|
+
/** design.md §27.4: "最大15分". */
|
|
222
|
+
export const IMPERSONATION_MAX_DURATION_MINUTES = 15;
|
|
223
|
+
|
|
224
|
+
/** design.md §27.4: "理由入力" is a required, non-empty justification. */
|
|
225
|
+
export const impersonationReasonSchema = z.string().min(10).max(1000);
|
|
226
|
+
|
|
227
|
+
export const impersonationCreateRequestSchema = z.strictObject({
|
|
228
|
+
user_id: userIdSchema,
|
|
229
|
+
reason: impersonationReasonSchema,
|
|
230
|
+
duration_minutes: z.number().int().min(1).max(IMPERSONATION_MAX_DURATION_MINUTES),
|
|
231
|
+
});
|
|
232
|
+
export type ImpersonationCreateRequest = z.infer<typeof impersonationCreateRequestSchema>;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* design.md §27.4: "UIに常時banner", "全操作をactorとimpersonated userの両方で
|
|
236
|
+
* 監査". The response carries both identities so the dashboard can render the
|
|
237
|
+
* mandatory banner and so every subsequent action can be dual-audited.
|
|
238
|
+
*/
|
|
239
|
+
export const impersonationSessionSchema = z.strictObject({
|
|
240
|
+
actor_user_id: userIdSchema,
|
|
241
|
+
impersonated_user_id: userIdSchema,
|
|
242
|
+
reason: impersonationReasonSchema,
|
|
243
|
+
started_at: z.iso.datetime(),
|
|
244
|
+
expires_at: z.iso.datetime(),
|
|
245
|
+
forbidden_scopes: z.array(managementScopeSchema),
|
|
246
|
+
});
|
|
247
|
+
export type ImpersonationSession = z.infer<typeof impersonationSessionSchema>;
|
|
248
|
+
|
|
249
|
+
export const impersonationCreateResponseSchema = impersonationSessionSchema;
|
|
250
|
+
export type ImpersonationCreateResponse = z.infer<typeof impersonationCreateResponseSchema>;
|