@camunda/ccma-shared-types 10.2.0 → 10.4.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/dist/agent-tokens/agent-tokens.d.ts +112 -0
- package/dist/agent-tokens/agent-tokens.js +63 -0
- package/dist/agent-tokens/agent-tokens.js.map +7 -0
- package/dist/agent-tokens/agent-tokens.spec.d.ts +1 -0
- package/dist/approvals/delete-cluster-corpses.d.ts +86 -0
- package/dist/approvals/delete-cluster-corpses.js +15 -0
- package/dist/approvals/delete-cluster-corpses.js.map +7 -0
- package/dist/approvals/force-delete-cluster-plan-type.d.ts +3 -2
- package/dist/approvals/force-delete-cluster-plan-type.js.map +2 -2
- package/dist/approvals/policy.d.ts +42 -3
- package/dist/approvals/policy.js +34 -3
- package/dist/approvals/policy.js.map +2 -2
- package/dist/approvals/use-cases.const.d.ts +1 -0
- package/dist/approvals/use-cases.const.js +4 -0
- package/dist/approvals/use-cases.const.js.map +2 -2
- package/dist/enums/SystemAuditEntity.enum.d.ts +2 -1
- package/dist/enums/SystemAuditEntity.enum.js +1 -0
- package/dist/enums/SystemAuditEntity.enum.js.map +2 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +47 -1
- package/dist/index.js.map +2 -2
- package/dist/permissions/accounts.permissions.d.ts +15 -0
- package/dist/permissions/accounts.permissions.js +37 -20
- package/dist/permissions/accounts.permissions.js.map +2 -2
- package/dist/permissions/combo.js +12 -1
- package/dist/permissions/combo.js.map +2 -2
- package/dist/permissions/console.permissions.d.ts +9 -0
- package/dist/permissions/console.permissions.js +9 -1
- package/dist/permissions/console.permissions.js.map +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent tokens of ccmacli: machine credentials that a Camundi creates in
|
|
3
|
+
* Camunda Control for an agent, with read access only.
|
|
4
|
+
*
|
|
5
|
+
* control-backend stores the clients. token-issuer exchanges the credentials
|
|
6
|
+
* for an Auth0 token of the agent audience. control-backend, console-backend
|
|
7
|
+
* and accounts-backend accept that token on their read routes. Every type and
|
|
8
|
+
* rule that two of these services share is in this file.
|
|
9
|
+
*/
|
|
10
|
+
/** AGENT_CLAIM is the claim of an agent token that names the client. */
|
|
11
|
+
export declare const AGENT_CLAIM = "https://camunda.com/agent";
|
|
12
|
+
/** AGENT_CLIENT_ID_PREFIX starts every client id of an agent client. */
|
|
13
|
+
export declare const AGENT_CLIENT_ID_PREFIX = "ccma_";
|
|
14
|
+
/** AGENT_TOKENS_FEATURE_FLAG closes create and rotate while it is off. */
|
|
15
|
+
export declare const AGENT_TOKENS_FEATURE_FLAG = "control.agentTokens";
|
|
16
|
+
/** AGENT_TOKEN_EXPIRY_DAYS lists the lifetimes that a creator can choose. */
|
|
17
|
+
export declare const AGENT_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90];
|
|
18
|
+
export type AgentTokenExpiryDays = (typeof AGENT_TOKEN_EXPIRY_DAYS)[number];
|
|
19
|
+
/** AGENT_TOKEN_NAME_MAX is the length limit of a name, after the trim. */
|
|
20
|
+
export declare const AGENT_TOKEN_NAME_MAX = 64;
|
|
21
|
+
/** AGENT_TOKENS_ACTIVE_MAX is the limit of active clients for one creator. */
|
|
22
|
+
export declare const AGENT_TOKENS_ACTIVE_MAX = 10;
|
|
23
|
+
/** Phase 1 has read access only. Phase 2 adds `write`. */
|
|
24
|
+
export type AgentAccess = 'read';
|
|
25
|
+
/** The value of `AGENT_CLAIM` in a verified agent token. */
|
|
26
|
+
export interface AgentClaim {
|
|
27
|
+
uuid: string;
|
|
28
|
+
access: AgentAccess;
|
|
29
|
+
/** The `sub` of the Camundi who created the client. */
|
|
30
|
+
createdByUserId: string;
|
|
31
|
+
}
|
|
32
|
+
/** `inactive` means that the creator is no longer a Camundi. */
|
|
33
|
+
export type AgentTokenStatus = 'active' | 'expired' | 'revoked' | 'inactive';
|
|
34
|
+
/** AGENT_TOKEN_STATUSES lists every value of `AgentTokenStatus`. */
|
|
35
|
+
export declare const AGENT_TOKEN_STATUSES: readonly ["active", "expired", "revoked", "inactive"];
|
|
36
|
+
/** One agent client, as its creator sees it. It never holds the secret. */
|
|
37
|
+
export interface AgentToken {
|
|
38
|
+
uuid: string;
|
|
39
|
+
clientId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
access: AgentAccess;
|
|
42
|
+
status: AgentTokenStatus;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
lastUsedAt?: string;
|
|
46
|
+
revokedAt?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* AgentTokenCreated is the answer of create and rotate. It is the only answer
|
|
50
|
+
* that holds `clientSecret`, and the backend cannot show the secret again.
|
|
51
|
+
*/
|
|
52
|
+
export interface AgentTokenCreated extends AgentToken {
|
|
53
|
+
clientSecret: string;
|
|
54
|
+
}
|
|
55
|
+
/** One agent client, as the admin list shows it. */
|
|
56
|
+
export interface AgentTokenAdmin extends AgentToken {
|
|
57
|
+
createdBy: {
|
|
58
|
+
userId: string;
|
|
59
|
+
email: string;
|
|
60
|
+
};
|
|
61
|
+
revokedByUserId?: string;
|
|
62
|
+
}
|
|
63
|
+
/** The body of `POST /api/agent-tokens`. */
|
|
64
|
+
export interface CreateAgentTokenDto {
|
|
65
|
+
name: string;
|
|
66
|
+
expiresInDays: AgentTokenExpiryDays;
|
|
67
|
+
}
|
|
68
|
+
/** The answer of `POST /internal/agent-clients/verify`. */
|
|
69
|
+
export interface AgentVerifyResponse {
|
|
70
|
+
uuid: string;
|
|
71
|
+
access: AgentAccess;
|
|
72
|
+
createdByUserId: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The answer of `GET /internal/agent-clients/:uuid/status`.
|
|
76
|
+
*
|
|
77
|
+
* `permissions` is `AGENT_READ_PERMISSIONS` of all backends, intersected with
|
|
78
|
+
* the permissions of the creator at create time. It is empty when `active` is
|
|
79
|
+
* false.
|
|
80
|
+
*/
|
|
81
|
+
export interface AgentStatusResponse {
|
|
82
|
+
active: boolean;
|
|
83
|
+
permissions: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* AGENT_READ_PERMISSIONS is the most that an agent token can do in each
|
|
87
|
+
* backend. A backend intersects its set with the `permissions` of the status
|
|
88
|
+
* answer, so an agent never holds a permission that its creator did not hold.
|
|
89
|
+
*
|
|
90
|
+
* `crtp` is a publish permission. It is here for one read only, the
|
|
91
|
+
* release-train sanity check. The agent allowlist of console-backend, which a
|
|
92
|
+
* later change of epic #10804 adds, must be the bound. Never add a route to
|
|
93
|
+
* that allowlist that `crtp` guards for a write.
|
|
94
|
+
*/
|
|
95
|
+
export declare const AGENT_READ_PERMISSIONS: Readonly<{
|
|
96
|
+
control: readonly string[];
|
|
97
|
+
console: readonly string[];
|
|
98
|
+
accounts: readonly string[];
|
|
99
|
+
}>;
|
|
100
|
+
/** agentReadPermissionUnion is the union of the three backend sets. */
|
|
101
|
+
export declare function agentReadPermissionUnion(): string[];
|
|
102
|
+
/** The codes of the two denials that only an agent token can get. */
|
|
103
|
+
export declare const AGENT_ERROR: {
|
|
104
|
+
readonly readOnly: "auth.agent_read_only";
|
|
105
|
+
readonly inactive: "auth.agent_inactive";
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* parseAgentClaim reads `AGENT_CLAIM` from the payload of a VERIFIED token.
|
|
109
|
+
* It returns `undefined` when the claim is absent or has a wrong shape, and
|
|
110
|
+
* the caller must then refuse the token.
|
|
111
|
+
*/
|
|
112
|
+
export declare function parseAgentClaim(payload: Record<string, unknown>): AgentClaim | undefined;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AccountsPermissions } from "../permissions/accounts.permissions.js";
|
|
2
|
+
import { ConsolePermissions } from "../permissions/console.permissions.js";
|
|
3
|
+
const AGENT_CLAIM = "https://camunda.com/agent";
|
|
4
|
+
const AGENT_CLIENT_ID_PREFIX = "ccma_";
|
|
5
|
+
const AGENT_TOKENS_FEATURE_FLAG = "control.agentTokens";
|
|
6
|
+
const AGENT_TOKEN_EXPIRY_DAYS = [7, 30, 90];
|
|
7
|
+
const AGENT_TOKEN_NAME_MAX = 64;
|
|
8
|
+
const AGENT_TOKENS_ACTIVE_MAX = 10;
|
|
9
|
+
const AGENT_TOKEN_STATUSES = [
|
|
10
|
+
"active",
|
|
11
|
+
"expired",
|
|
12
|
+
"revoked",
|
|
13
|
+
"inactive"
|
|
14
|
+
];
|
|
15
|
+
const AGENT_READ_PERMISSIONS = {
|
|
16
|
+
control: [AccountsPermissions.READ_ALL],
|
|
17
|
+
console: [
|
|
18
|
+
ConsolePermissions.CHANNELS_AND_GENERATIONS_ADMIN_SEE,
|
|
19
|
+
ConsolePermissions.CLUSTERS_READALLCLUSTERINFO,
|
|
20
|
+
ConsolePermissions.CLUSTER_PLAN_SEE_ALL,
|
|
21
|
+
ConsolePermissions.RELEASE_TRAIN_PUBLISH
|
|
22
|
+
],
|
|
23
|
+
accounts: [AccountsPermissions.READ_ALL]
|
|
24
|
+
};
|
|
25
|
+
function agentReadPermissionUnion() {
|
|
26
|
+
return [
|
|
27
|
+
.../* @__PURE__ */ new Set([
|
|
28
|
+
...AGENT_READ_PERMISSIONS.control,
|
|
29
|
+
...AGENT_READ_PERMISSIONS.console,
|
|
30
|
+
...AGENT_READ_PERMISSIONS.accounts
|
|
31
|
+
])
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
const AGENT_ERROR = {
|
|
35
|
+
readOnly: "auth.agent_read_only",
|
|
36
|
+
inactive: "auth.agent_inactive"
|
|
37
|
+
};
|
|
38
|
+
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
39
|
+
function parseAgentClaim(payload) {
|
|
40
|
+
const claim = payload[AGENT_CLAIM];
|
|
41
|
+
if (typeof claim !== "object" || claim === null || Array.isArray(claim)) {
|
|
42
|
+
return void 0;
|
|
43
|
+
}
|
|
44
|
+
const { uuid, access, createdByUserId } = claim;
|
|
45
|
+
if (typeof uuid !== "string" || !UUID_SHAPE.test(uuid) || access !== "read" || typeof createdByUserId !== "string" || createdByUserId.length === 0) {
|
|
46
|
+
return void 0;
|
|
47
|
+
}
|
|
48
|
+
return { uuid, access, createdByUserId };
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
AGENT_CLAIM,
|
|
52
|
+
AGENT_CLIENT_ID_PREFIX,
|
|
53
|
+
AGENT_ERROR,
|
|
54
|
+
AGENT_READ_PERMISSIONS,
|
|
55
|
+
AGENT_TOKENS_ACTIVE_MAX,
|
|
56
|
+
AGENT_TOKENS_FEATURE_FLAG,
|
|
57
|
+
AGENT_TOKEN_EXPIRY_DAYS,
|
|
58
|
+
AGENT_TOKEN_NAME_MAX,
|
|
59
|
+
AGENT_TOKEN_STATUSES,
|
|
60
|
+
agentReadPermissionUnion,
|
|
61
|
+
parseAgentClaim
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=agent-tokens.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/agent-tokens/agent-tokens.ts"],
|
|
4
|
+
"sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { AccountsPermissions } from '../permissions/accounts.permissions.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\n\n/**\n * The agent tokens of ccmacli: machine credentials that a Camundi creates in\n * Camunda Control for an agent, with read access only.\n *\n * control-backend stores the clients. token-issuer exchanges the credentials\n * for an Auth0 token of the agent audience. control-backend, console-backend\n * and accounts-backend accept that token on their read routes. Every type and\n * rule that two of these services share is in this file.\n */\n\n/** AGENT_CLAIM is the claim of an agent token that names the client. */\nexport const AGENT_CLAIM = 'https://camunda.com/agent'\n\n/** AGENT_CLIENT_ID_PREFIX starts every client id of an agent client. */\nexport const AGENT_CLIENT_ID_PREFIX = 'ccma_'\n\n/** AGENT_TOKENS_FEATURE_FLAG closes create and rotate while it is off. */\nexport const AGENT_TOKENS_FEATURE_FLAG = 'control.agentTokens'\n\n/** AGENT_TOKEN_EXPIRY_DAYS lists the lifetimes that a creator can choose. */\nexport const AGENT_TOKEN_EXPIRY_DAYS = [7, 30, 90] as const\n\nexport type AgentTokenExpiryDays = (typeof AGENT_TOKEN_EXPIRY_DAYS)[number]\n\n/** AGENT_TOKEN_NAME_MAX is the length limit of a name, after the trim. */\nexport const AGENT_TOKEN_NAME_MAX = 64\n\n/** AGENT_TOKENS_ACTIVE_MAX is the limit of active clients for one creator. */\nexport const AGENT_TOKENS_ACTIVE_MAX = 10\n\n/** Phase 1 has read access only. Phase 2 adds `write`. */\nexport type AgentAccess = 'read'\n\n/** The value of `AGENT_CLAIM` in a verified agent token. */\nexport interface AgentClaim {\n uuid: string\n access: AgentAccess\n /** The `sub` of the Camundi who created the client. */\n createdByUserId: string\n}\n\n/** `inactive` means that the creator is no longer a Camundi. */\nexport type AgentTokenStatus = 'active' | 'expired' | 'revoked' | 'inactive'\n\n/** AGENT_TOKEN_STATUSES lists every value of `AgentTokenStatus`. */\nexport const AGENT_TOKEN_STATUSES = [\n 'active',\n 'expired',\n 'revoked',\n 'inactive',\n] as const satisfies readonly AgentTokenStatus[]\n\n/** One agent client, as its creator sees it. It never holds the secret. */\nexport interface AgentToken {\n uuid: string\n clientId: string\n name: string\n access: AgentAccess\n status: AgentTokenStatus\n createdAt: string\n expiresAt: string\n lastUsedAt?: string\n revokedAt?: string\n}\n\n/**\n * AgentTokenCreated is the answer of create and rotate. It is the only answer\n * that holds `clientSecret`, and the backend cannot show the secret again.\n */\nexport interface AgentTokenCreated extends AgentToken {\n clientSecret: string\n}\n\n/** One agent client, as the admin list shows it. */\nexport interface AgentTokenAdmin extends AgentToken {\n createdBy: { userId: string; email: string }\n revokedByUserId?: string\n}\n\n/** The body of `POST /api/agent-tokens`. */\nexport interface CreateAgentTokenDto {\n name: string\n expiresInDays: AgentTokenExpiryDays\n}\n\n/** The answer of `POST /internal/agent-clients/verify`. */\nexport interface AgentVerifyResponse {\n uuid: string\n access: AgentAccess\n createdByUserId: string\n}\n\n/**\n * The answer of `GET /internal/agent-clients/:uuid/status`.\n *\n * `permissions` is `AGENT_READ_PERMISSIONS` of all backends, intersected with\n * the permissions of the creator at create time. It is empty when `active` is\n * false.\n */\nexport interface AgentStatusResponse {\n active: boolean\n permissions: string[]\n}\n\n/**\n * AGENT_READ_PERMISSIONS is the most that an agent token can do in each\n * backend. A backend intersects its set with the `permissions` of the status\n * answer, so an agent never holds a permission that its creator did not hold.\n *\n * `crtp` is a publish permission. It is here for one read only, the\n * release-train sanity check. The agent allowlist of console-backend, which a\n * later change of epic #10804 adds, must be the bound. Never add a route to\n * that allowlist that `crtp` guards for a write.\n */\nexport const AGENT_READ_PERMISSIONS: Readonly<{\n control: readonly string[]\n console: readonly string[]\n accounts: readonly string[]\n}> = {\n control: [AccountsPermissions.READ_ALL],\n console: [\n ConsolePermissions.CHANNELS_AND_GENERATIONS_ADMIN_SEE,\n ConsolePermissions.CLUSTERS_READALLCLUSTERINFO,\n ConsolePermissions.CLUSTER_PLAN_SEE_ALL,\n ConsolePermissions.RELEASE_TRAIN_PUBLISH,\n ],\n accounts: [AccountsPermissions.READ_ALL],\n}\n\n/** agentReadPermissionUnion is the union of the three backend sets. */\nexport function agentReadPermissionUnion(): string[] {\n return [\n ...new Set([\n ...AGENT_READ_PERMISSIONS.control,\n ...AGENT_READ_PERMISSIONS.console,\n ...AGENT_READ_PERMISSIONS.accounts,\n ]),\n ]\n}\n\n/** The codes of the two denials that only an agent token can get. */\nexport const AGENT_ERROR = {\n readOnly: 'auth.agent_read_only',\n inactive: 'auth.agent_inactive',\n} as const\n\nconst UUID_SHAPE =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * parseAgentClaim reads `AGENT_CLAIM` from the payload of a VERIFIED token.\n * It returns `undefined` when the claim is absent or has a wrong shape, and\n * the caller must then refuse the token.\n */\nexport function parseAgentClaim(\n payload: Record<string, unknown>,\n): AgentClaim | undefined {\n const claim = payload[AGENT_CLAIM]\n if (typeof claim !== 'object' || claim === null || Array.isArray(claim)) {\n return undefined\n }\n const { uuid, access, createdByUserId } = claim as Record<string, unknown>\n if (\n typeof uuid !== 'string' ||\n !UUID_SHAPE.test(uuid) ||\n access !== 'read' ||\n typeof createdByUserId !== 'string' ||\n createdByUserId.length === 0\n ) {\n return undefined\n }\n return { uuid, access, createdByUserId }\n}\n"],
|
|
5
|
+
"mappings": "AAMA,SAAS,2BAA2B;AACpC,SAAS,0BAA0B;AAa5B,MAAM,cAAc;AAGpB,MAAM,yBAAyB;AAG/B,MAAM,4BAA4B;AAGlC,MAAM,0BAA0B,CAAC,GAAG,IAAI,EAAE;AAK1C,MAAM,uBAAuB;AAG7B,MAAM,0BAA0B;AAiBhC,MAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAgEO,MAAM,yBAIR;AAAA,EACH,SAAS,CAAC,oBAAoB,QAAQ;AAAA,EACtC,SAAS;AAAA,IACP,mBAAmB;AAAA,IACnB,mBAAmB;AAAA,IACnB,mBAAmB;AAAA,IACnB,mBAAmB;AAAA,EACrB;AAAA,EACA,UAAU,CAAC,oBAAoB,QAAQ;AACzC;AAGO,SAAS,2BAAqC;AACnD,SAAO;AAAA,IACL,GAAG,oBAAI,IAAI;AAAA,MACT,GAAG,uBAAuB;AAAA,MAC1B,GAAG,uBAAuB;AAAA,MAC1B,GAAG,uBAAuB;AAAA,IAC5B,CAAC;AAAA,EACH;AACF;AAGO,MAAM,cAAc;AAAA,EACzB,UAAU;AAAA,EACV,UAAU;AACZ;AAEA,MAAM,aACJ;AAOK,SAAS,gBACd,SACwB;AACxB,QAAM,QAAQ,QAAQ,WAAW;AACjC,MAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,GAAG;AACvE,WAAO;AAAA,EACT;AACA,QAAM,EAAE,MAAM,QAAQ,gBAAgB,IAAI;AAC1C,MACE,OAAO,SAAS,YAChB,CAAC,WAAW,KAAK,IAAI,KACrB,WAAW,UACX,OAAO,oBAAoB,YAC3B,gBAAgB,WAAW,GAC3B;AACA,WAAO;AAAA,EACT;AACA,SAAO,EAAE,MAAM,QAAQ,gBAAgB;AACzC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flag of the `delete-cluster-corpses` use case.
|
|
3
|
+
*
|
|
4
|
+
* control-backend opens a request only while it is on. Control shows the
|
|
5
|
+
* request in place of the direct "Delete all corpses" while it is on.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DELETE_CLUSTER_CORPSES_FLAG = "control.deleteClusterCorpses";
|
|
8
|
+
/**
|
|
9
|
+
* The two kinds of cluster corpse.
|
|
10
|
+
*
|
|
11
|
+
* - `db`: a cluster row of console with no ZeebeCluster resource in
|
|
12
|
+
* Kubernetes. The delete removes the row and what belongs to it.
|
|
13
|
+
* - `k8s`: a ZeebeCluster resource in Kubernetes with no cluster row of
|
|
14
|
+
* console. The delete removes the resource.
|
|
15
|
+
*/
|
|
16
|
+
export declare const CLUSTER_CORPSE_KINDS: readonly ["db", "k8s"];
|
|
17
|
+
export type ClusterCorpseKind = (typeof CLUSTER_CORPSE_KINDS)[number];
|
|
18
|
+
/**
|
|
19
|
+
* The most corpses that one request deletes.
|
|
20
|
+
*
|
|
21
|
+
* The request lists each corpse, so that the approvers read what they delete.
|
|
22
|
+
* When there are more, the request takes the oldest ones, and a later request
|
|
23
|
+
* takes the rest.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DELETE_CLUSTER_CORPSES_MAX = 500;
|
|
26
|
+
/**
|
|
27
|
+
* The youngest corpse that a request takes, in milliseconds.
|
|
28
|
+
*
|
|
29
|
+
* Console creates the Kubernetes resource of a new cluster before it commits
|
|
30
|
+
* the row, and its watch of Kubernetes sees a new resource some time later.
|
|
31
|
+
* So a cluster that is being created can look like either kind of corpse for
|
|
32
|
+
* a short time.
|
|
33
|
+
*/
|
|
34
|
+
export declare const CLUSTER_CORPSE_MIN_AGE_MS: number;
|
|
35
|
+
/**
|
|
36
|
+
* The approvals of a corpse deletion on prod: two people other than the
|
|
37
|
+
* person who asks, the same as a cluster bulk deletion
|
|
38
|
+
* (`CLUSTER_BULK_OPERATION_MIN_ACKS`).
|
|
39
|
+
*/
|
|
40
|
+
export declare const DELETE_CLUSTER_CORPSES_MIN_ACKS_PROD = 2;
|
|
41
|
+
/** The approvals of a corpse deletion on dev, int and local. */
|
|
42
|
+
export declare const DELETE_CLUSTER_CORPSES_MIN_ACKS_TEST_STAGE = 1;
|
|
43
|
+
/** One corpse, as console answers it to control-backend. */
|
|
44
|
+
export interface ClusterCorpseDto {
|
|
45
|
+
/** The uuid of the cluster row, or the name of the Kubernetes resource. */
|
|
46
|
+
clusterId: string;
|
|
47
|
+
orgId: string;
|
|
48
|
+
/** The name of the cluster. Only a `db` corpse has one. */
|
|
49
|
+
name?: string;
|
|
50
|
+
/**
|
|
51
|
+
* The Kubernetes context. A `k8s` corpse always has one. A `db` corpse has
|
|
52
|
+
* the context of its row, when the row names one.
|
|
53
|
+
*/
|
|
54
|
+
k8sContextUuid?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The `metadata.uid` of the resource of a `k8s` corpse. Console deletes the
|
|
57
|
+
* resource only while it has this uid, so a new resource with the same name
|
|
58
|
+
* stays. A `k8s` corpse without it never goes into a request.
|
|
59
|
+
*/
|
|
60
|
+
resourceUid?: string;
|
|
61
|
+
/**
|
|
62
|
+
* When the row or the resource was created, as an ISO 8601 string. A
|
|
63
|
+
* corpse without it is never old enough for a request.
|
|
64
|
+
*/
|
|
65
|
+
created?: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The corpses of one kind now. Console answers it on
|
|
69
|
+
* `GET api/admin/approvals/cluster-corpses/:kind`.
|
|
70
|
+
*/
|
|
71
|
+
export interface ClusterCorpsesDto {
|
|
72
|
+
kind: ClusterCorpseKind;
|
|
73
|
+
corpses: ClusterCorpseDto[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What console did with one corpse of a request.
|
|
77
|
+
*
|
|
78
|
+
* Console reads the live source again before it deletes. `not-a-corpse` means
|
|
79
|
+
* that the cluster is not a corpse any more: a `db` corpse has a resource in
|
|
80
|
+
* Kubernetes now, or a `k8s` corpse has a row now. `gone` means that there was
|
|
81
|
+
* nothing to delete: the row or the resource does not exist, or the resource
|
|
82
|
+
* of a `k8s` corpse has another uid now. Console deletes nothing for both.
|
|
83
|
+
*/
|
|
84
|
+
export interface DeleteClusterCorpseResult {
|
|
85
|
+
outcome: 'deleted' | 'gone' | 'not-a-corpse';
|
|
86
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const DELETE_CLUSTER_CORPSES_FLAG = "control.deleteClusterCorpses";
|
|
2
|
+
const CLUSTER_CORPSE_KINDS = ["db", "k8s"];
|
|
3
|
+
const DELETE_CLUSTER_CORPSES_MAX = 500;
|
|
4
|
+
const CLUSTER_CORPSE_MIN_AGE_MS = 60 * 60 * 1e3;
|
|
5
|
+
const DELETE_CLUSTER_CORPSES_MIN_ACKS_PROD = 2;
|
|
6
|
+
const DELETE_CLUSTER_CORPSES_MIN_ACKS_TEST_STAGE = 1;
|
|
7
|
+
export {
|
|
8
|
+
CLUSTER_CORPSE_KINDS,
|
|
9
|
+
CLUSTER_CORPSE_MIN_AGE_MS,
|
|
10
|
+
DELETE_CLUSTER_CORPSES_FLAG,
|
|
11
|
+
DELETE_CLUSTER_CORPSES_MAX,
|
|
12
|
+
DELETE_CLUSTER_CORPSES_MIN_ACKS_PROD,
|
|
13
|
+
DELETE_CLUSTER_CORPSES_MIN_ACKS_TEST_STAGE
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=delete-cluster-corpses.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/approvals/delete-cluster-corpses.ts"],
|
|
4
|
+
"sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\n/**\n * The flag of the `delete-cluster-corpses` use case.\n *\n * control-backend opens a request only while it is on. Control shows the\n * request in place of the direct \"Delete all corpses\" while it is on.\n */\nexport const DELETE_CLUSTER_CORPSES_FLAG = 'control.deleteClusterCorpses'\n\n/**\n * The two kinds of cluster corpse.\n *\n * - `db`: a cluster row of console with no ZeebeCluster resource in\n * Kubernetes. The delete removes the row and what belongs to it.\n * - `k8s`: a ZeebeCluster resource in Kubernetes with no cluster row of\n * console. The delete removes the resource.\n */\nexport const CLUSTER_CORPSE_KINDS = ['db', 'k8s'] as const\n\nexport type ClusterCorpseKind = (typeof CLUSTER_CORPSE_KINDS)[number]\n\n/**\n * The most corpses that one request deletes.\n *\n * The request lists each corpse, so that the approvers read what they delete.\n * When there are more, the request takes the oldest ones, and a later request\n * takes the rest.\n */\nexport const DELETE_CLUSTER_CORPSES_MAX = 500\n\n/**\n * The youngest corpse that a request takes, in milliseconds.\n *\n * Console creates the Kubernetes resource of a new cluster before it commits\n * the row, and its watch of Kubernetes sees a new resource some time later.\n * So a cluster that is being created can look like either kind of corpse for\n * a short time.\n */\nexport const CLUSTER_CORPSE_MIN_AGE_MS = 60 * 60 * 1000\n\n/**\n * The approvals of a corpse deletion on prod: two people other than the\n * person who asks, the same as a cluster bulk deletion\n * (`CLUSTER_BULK_OPERATION_MIN_ACKS`).\n */\nexport const DELETE_CLUSTER_CORPSES_MIN_ACKS_PROD = 2\n\n/** The approvals of a corpse deletion on dev, int and local. */\nexport const DELETE_CLUSTER_CORPSES_MIN_ACKS_TEST_STAGE = 1\n\n/** One corpse, as console answers it to control-backend. */\nexport interface ClusterCorpseDto {\n /** The uuid of the cluster row, or the name of the Kubernetes resource. */\n clusterId: string\n orgId: string\n /** The name of the cluster. Only a `db` corpse has one. */\n name?: string\n /**\n * The Kubernetes context. A `k8s` corpse always has one. A `db` corpse has\n * the context of its row, when the row names one.\n */\n k8sContextUuid?: string\n /**\n * The `metadata.uid` of the resource of a `k8s` corpse. Console deletes the\n * resource only while it has this uid, so a new resource with the same name\n * stays. A `k8s` corpse without it never goes into a request.\n */\n resourceUid?: string\n /**\n * When the row or the resource was created, as an ISO 8601 string. A\n * corpse without it is never old enough for a request.\n */\n created?: string\n}\n\n/**\n * The corpses of one kind now. Console answers it on\n * `GET api/admin/approvals/cluster-corpses/:kind`.\n */\nexport interface ClusterCorpsesDto {\n kind: ClusterCorpseKind\n corpses: ClusterCorpseDto[]\n}\n\n/**\n * What console did with one corpse of a request.\n *\n * Console reads the live source again before it deletes. `not-a-corpse` means\n * that the cluster is not a corpse any more: a `db` corpse has a resource in\n * Kubernetes now, or a `k8s` corpse has a row now. `gone` means that there was\n * nothing to delete: the row or the resource does not exist, or the resource\n * of a `k8s` corpse has another uid now. Console deletes nothing for both.\n */\nexport interface DeleteClusterCorpseResult {\n outcome: 'deleted' | 'gone' | 'not-a-corpse'\n}\n"],
|
|
5
|
+
"mappings": "AAYO,MAAM,8BAA8B;AAUpC,MAAM,uBAAuB,CAAC,MAAM,KAAK;AAWzC,MAAM,6BAA6B;AAUnC,MAAM,4BAA4B,KAAK,KAAK;AAO5C,MAAM,uCAAuC;AAG7C,MAAM,6CAA6C;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Three services read it. control-backend opens a request only while it is
|
|
5
5
|
* on. Control shows the request in place of the direct force delete. Console
|
|
6
|
-
* refuses the direct force delete route
|
|
7
|
-
* `
|
|
6
|
+
* refuses the direct force delete route to a person only while
|
|
7
|
+
* `control.lockDirectRoutes` is on too. While the lock is off, a person who
|
|
8
|
+
* holds `cptfd` can still call that route and skip the approvals.
|
|
8
9
|
*/
|
|
9
10
|
export declare const FORCE_DELETE_CLUSTER_PLAN_TYPE_FLAG = "control.forceDeleteClusterPlanType";
|
|
10
11
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/approvals/force-delete-cluster-plan-type.ts"],
|
|
4
|
-
"sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\n/**\n * The flag of the `force-delete-cluster-plan-type` use case.\n *\n * Three services read it. control-backend opens a request only while it is\n * on. Control shows the request in place of the direct force delete. Console\n * refuses the direct force delete route while
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\n/**\n * The flag of the `force-delete-cluster-plan-type` use case.\n *\n * Three services read it. control-backend opens a request only while it is\n * on. Control shows the request in place of the direct force delete. Console\n * refuses the direct force delete route to a person only while\n * `control.lockDirectRoutes` is on too. While the lock is off, a person who\n * holds `cptfd` can still call that route and skip the approvals.\n */\nexport const FORCE_DELETE_CLUSTER_PLAN_TYPE_FLAG =\n 'control.forceDeleteClusterPlanType'\n\n/**\n * The most clusters that one force delete request can delete.\n *\n * The request lists each cluster, so that the approvers read what they\n * delete. A plan type with more clusters needs a cluster bulk deletion first.\n */\nexport const FORCE_DELETE_CLUSTER_PLAN_TYPE_MAX_CLUSTERS = 500\n\n/**\n * The approvals of a force delete on prod: two people other than the person\n * who asks. The request deletes customer clusters of many organizations, the\n * same harm as a cluster bulk deletion (`CLUSTER_BULK_OPERATION_MIN_ACKS`).\n */\nexport const FORCE_DELETE_CLUSTER_PLAN_TYPE_MIN_ACKS_PROD = 2\n\n/** The approvals of a force delete on dev, int and local. */\nexport const FORCE_DELETE_CLUSTER_PLAN_TYPE_MIN_ACKS_TEST_STAGE = 1\n\n/** One cluster of a plan type, as console answers it to control-backend. */\nexport interface ClusterPlanTypeClusterDto {\n id: string\n name: string\n orgId: string\n}\n\n/**\n * The clusters of a plan type now. Console answers it on\n * `GET api/admin/approvals/cluster-plan-types/:planTypeId/clusters`.\n */\nexport interface ClusterPlanTypeClustersDto {\n planTypeId: string\n planTypeName: string\n clusters: ClusterPlanTypeClusterDto[]\n}\n\n/**\n * What console did with one step of a force delete.\n *\n * `gone` means that there was nothing to delete: the cluster or the plan type\n * does not exist, or the cluster moved to another plan type. A second run\n * then reads `gone` for a step that the first run finished.\n */\nexport interface ForceDeleteClusterPlanTypeStepResult {\n outcome: 'deleted' | 'gone'\n}\n"],
|
|
5
|
+
"mappings": "AAeO,MAAM,sCACX;AAQK,MAAM,8CAA8C;AAOpD,MAAM,+CAA+C;AAGrD,MAAM,qDAAqD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -103,7 +103,11 @@ export type ApprovalDecision = {
|
|
|
103
103
|
* keeps that state, because "expired" would lose the answer that was given.
|
|
104
104
|
*/
|
|
105
105
|
export declare function isExpired(job: ApprovalJobDto, now: Date): boolean;
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Reports the status to show. It is `expired` once the time to answer has
|
|
108
|
+
* passed, or once the run window of an `approved` or `failed` request has
|
|
109
|
+
* ended. control-backend writes both, and this shows them before the write.
|
|
110
|
+
*/
|
|
107
111
|
export declare function effectiveStatus(job: ApprovalJobDto, now: Date): ApprovalJobDto['status'];
|
|
108
112
|
/** Reports how many more acknowledgements the request needs. */
|
|
109
113
|
export declare function remainingAcks(job: ApprovalJobDto): number;
|
|
@@ -220,7 +224,8 @@ export declare function canWithdraw(job: ApprovalJobDto, actor: ApprovalActorVie
|
|
|
220
224
|
*
|
|
221
225
|
* This is the button of the explicit mode. `failed` is executable again,
|
|
222
226
|
* because a failure is often a problem of the system that is now repaired, and
|
|
223
|
-
* asking for the approvals a second time helps nobody.
|
|
227
|
+
* asking for the approvals a second time helps nobody. Both run only inside
|
|
228
|
+
* the run window, see `EXECUTE_WINDOW_DAYS`.
|
|
224
229
|
*
|
|
225
230
|
* ANY holder of the permission may press it, and not only the person who
|
|
226
231
|
* opened the request. The decision was made when the request reached
|
|
@@ -235,4 +240,38 @@ export declare function canWithdraw(job: ApprovalJobDto, actor: ApprovalActorVie
|
|
|
235
240
|
* an action that names a person to another service names the creator. See
|
|
236
241
|
* `onBehalfOf` of `extend-trial`.
|
|
237
242
|
*/
|
|
238
|
-
export declare function canExecute(job: ApprovalJobDto, actor: ApprovalActorView): ApprovalDecision;
|
|
243
|
+
export declare function canExecute(job: ApprovalJobDto, actor: ApprovalActorView, now?: Date): ApprovalDecision;
|
|
244
|
+
/**
|
|
245
|
+
* How many days a request can run after its last approval.
|
|
246
|
+
*
|
|
247
|
+
* The approvers agreed to the request as the world was on that day. Later the
|
|
248
|
+
* facts can change: an organization in trial becomes a paying customer. A
|
|
249
|
+
* request that is older than this window needs new approvals. The rule is here
|
|
250
|
+
* and not in each use case, so a new use case cannot forget it.
|
|
251
|
+
*
|
|
252
|
+
* A `failed` request counts from the same day. A retry is for a problem of the
|
|
253
|
+
* system that is now repaired, not for a run weeks later.
|
|
254
|
+
*/
|
|
255
|
+
export declare const EXECUTE_WINDOW_DAYS = 7;
|
|
256
|
+
/**
|
|
257
|
+
* Reports the end of the run window of a request.
|
|
258
|
+
*
|
|
259
|
+
* The window starts at the last approval. A use case that needs zero
|
|
260
|
+
* approvals has none, so its window starts when the request was opened.
|
|
261
|
+
*/
|
|
262
|
+
export declare function runWindowEnd(job: ApprovalJobDto): Date;
|
|
263
|
+
/**
|
|
264
|
+
* Reports whether an `approved` or `failed` request is past its run window.
|
|
265
|
+
*
|
|
266
|
+
* It fails closed: a date that does not parse gives NaN, and NaN closes the
|
|
267
|
+
* window. The window guards a privileged action.
|
|
268
|
+
*/
|
|
269
|
+
export declare function isRunWindowClosed(job: ApprovalJobDto, now: Date): boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Reports whether a request ran, failed, and will not run again by itself.
|
|
272
|
+
*
|
|
273
|
+
* That is a `failed` request, or a failed request that control-backend then
|
|
274
|
+
* wrote as `expired` at the end of its run window. Only a run raises
|
|
275
|
+
* `attempts`, and an `executed` request never expires.
|
|
276
|
+
*/
|
|
277
|
+
export declare function hasFailedRun(job: ApprovalJobDto): boolean;
|
package/dist/approvals/policy.js
CHANGED
|
@@ -26,7 +26,7 @@ function isExpired(job, now) {
|
|
|
26
26
|
return job.status === "pending" && new Date(job.expiresAt).getTime() <= now.getTime();
|
|
27
27
|
}
|
|
28
28
|
function effectiveStatus(job, now) {
|
|
29
|
-
return isExpired(job, now) ? "expired" : job.status;
|
|
29
|
+
return isExpired(job, now) || isRunWindowClosed(job, now) ? "expired" : job.status;
|
|
30
30
|
}
|
|
31
31
|
function remainingAcks(job) {
|
|
32
32
|
return Math.max(0, job.requiredAcks - job.acks.length);
|
|
@@ -168,19 +168,47 @@ function canWithdraw(job, actor, now) {
|
|
|
168
168
|
}
|
|
169
169
|
return ALLOWED;
|
|
170
170
|
}
|
|
171
|
-
function canExecute(job, actor) {
|
|
171
|
+
function canExecute(job, actor, now = /* @__PURE__ */ new Date()) {
|
|
172
172
|
if (!hasEveryPermission(actor, job.requiredPermissions)) {
|
|
173
173
|
return deny("forbidden", "You cannot run this request.");
|
|
174
174
|
}
|
|
175
|
+
if (job.status === "expired") {
|
|
176
|
+
return deny("expired", "This request is expired. Open a new request.");
|
|
177
|
+
}
|
|
175
178
|
if (job.status !== "approved" && job.status !== "failed") {
|
|
176
179
|
return deny(
|
|
177
180
|
"not-executable",
|
|
178
181
|
`This request is ${job.status} and cannot be run.`
|
|
179
182
|
);
|
|
180
183
|
}
|
|
184
|
+
if (isRunWindowClosed(job, now)) {
|
|
185
|
+
return deny(
|
|
186
|
+
"expired",
|
|
187
|
+
`The approvals of this request are older than ${EXECUTE_WINDOW_DAYS} days. Open a new request.`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
181
190
|
return ALLOWED;
|
|
182
191
|
}
|
|
192
|
+
const EXECUTE_WINDOW_DAYS = 7;
|
|
193
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
194
|
+
function runWindowEnd(job) {
|
|
195
|
+
const start = job.acks.reduce(
|
|
196
|
+
(latest, ack) => Math.max(latest, new Date(ack.date).getTime()),
|
|
197
|
+
new Date(job.createdAt).getTime()
|
|
198
|
+
);
|
|
199
|
+
return new Date(start + EXECUTE_WINDOW_DAYS * DAY_MS);
|
|
200
|
+
}
|
|
201
|
+
function isRunWindowClosed(job, now) {
|
|
202
|
+
if (job.status !== "approved" && job.status !== "failed") {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
return !(runWindowEnd(job).getTime() > now.getTime());
|
|
206
|
+
}
|
|
207
|
+
function hasFailedRun(job) {
|
|
208
|
+
return job.status === "failed" || job.status === "expired" && job.attempts > 0;
|
|
209
|
+
}
|
|
183
210
|
export {
|
|
211
|
+
EXECUTE_WINDOW_DAYS,
|
|
184
212
|
approvalPermissionCheck,
|
|
185
213
|
canAcknowledge,
|
|
186
214
|
canCreate,
|
|
@@ -191,11 +219,14 @@ export {
|
|
|
191
219
|
effectiveStatus,
|
|
192
220
|
hasActorEmail,
|
|
193
221
|
hasEveryPermission,
|
|
222
|
+
hasFailedRun,
|
|
194
223
|
isExpired,
|
|
224
|
+
isRunWindowClosed,
|
|
195
225
|
isSameActor,
|
|
196
226
|
isSameActorEmail,
|
|
197
227
|
remainingAcks,
|
|
198
228
|
requiredPermissionsOf,
|
|
199
|
-
resolveRequiredAcks
|
|
229
|
+
resolveRequiredAcks,
|
|
230
|
+
runWindowEnd
|
|
200
231
|
};
|
|
201
232
|
//# sourceMappingURL=policy.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/approvals/policy.ts"],
|
|
4
|
-
"sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { UNKNOWN_ACTOR_EMAIL } from '../constants/misc.const.js'\nimport type {\n ApprovalJobDto,\n ApprovalUseCaseDto,\n} from '../dtos/ApprovalJob.dto.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\n\n/**\n * The rules of the approval mechanism, as pure functions.\n *\n * Three consumers run this file: control-backend decides whether to reject a\n * request, control-frontend decides whether to disable a button, and cloudbot\n * decides whether to answer. One copy means the button and the endpoint can\n * never disagree.\n *\n * Two choices in the signatures carry the design:\n *\n * - The actor is an interface with a `has` method. The backend holds an array\n * of Auth0 claims, and `useSession()` in the frontend gives a flat record of\n * booleans. Neither side has to convert to the shape of the other.\n * - `now` is a parameter. The expiry is lazy, so a stored `pending` request can\n * be expired already. A parameter makes a difference between the two clocks\n * an input that a test can set, and not a silent disagreement.\n *\n * No function here does I/O or returns a promise. The browser call site is what\n * enforces that, and it also stops a later use case from turning a list of 50\n * rows into 200 network calls.\n */\n\n/** Who is asking. See the note above on why this is an interface. */\nexport interface ApprovalActorView {\n userId: string\n /**\n * The email of the actor. It is the SECOND identity of the four-eyes rule.\n *\n * `userId` alone is not enough. One person can hold more than one identity in\n * Auth0 \u2014 a connection through SSO and a connection through a database, or an\n * account that was made again \u2014 and each one carries a different `sub`. The\n * same human then opens a request with one identity and approves it with the\n * other, which is the rule defeated.\n *\n * It is optional because a token can carry no email. See\n * `isSameActorEmail` for what that means for the comparison.\n */\n email?: string\n has(permission: string): boolean\n}\n\n/**\n * Builds the `has` of an actor from the permissions that the actor holds.\n *\n * `ConsolePermissions.APPROVALS_ALL_USE_CASES` stands in for every permission\n * that a use case asks for. Both actor builders call this function, the one of\n * control-backend and the one of the browser, so the button and the route\n * agree on it. A rule that reads `actor.has` needs no change for it, and nor\n * does a use case that a later change adds.\n */\nexport function approvalPermissionCheck(\n held: ReadonlySet<string>,\n): (permission: string) => boolean {\n const all = held.has(ConsolePermissions.APPROVALS_ALL_USE_CASES)\n return (permission) => all || held.has(permission)\n}\n\n/**\n * Reports whether two email addresses name one person.\n *\n * Two values that are absent, empty, or `UNKNOWN_ACTOR_EMAIL` are NOT one\n * person. A token with no email would otherwise make every actor equal to\n * every other actor, and the rule would refuse everybody.\n *\n * The comparison removes the spaces and the case. Auth0 gives the address as\n * the identity provider wrote it, so `A.B@camunda.com` and `a.b@camunda.com`\n * are one mailbox and must not pass as two people.\n */\nexport function isSameActorEmail(one?: string, other?: string): boolean {\n const left = comparableActorEmail(one)\n return left.length > 0 && left === comparableActorEmail(other)\n}\n\n/**\n * The address in the one form that a comparison may use.\n *\n * An absent address, an empty one and `UNKNOWN_ACTOR_EMAIL` all become an\n * empty string, because none of the three names a person. The spaces and the\n * case go as well: Auth0 gives the address as the identity provider wrote it.\n */\nfunction comparableActorEmail(value?: string): string {\n const trimmed = value?.trim().toLowerCase() ?? ''\n return trimmed === UNKNOWN_ACTOR_EMAIL ? '' : trimmed\n}\n\n/**\n * Reports whether one identity of a request names the actor.\n *\n * The `sub` of the token OR the email, because one person can hold more than\n * one Auth0 identity. Every rule that asks \"is this the same human?\" reads both\n * through this function, so a rule cannot forget the second half: `canWithdraw`\n * did, and it refused a creator whose browser and whose service resolved the\n * id from two different tokens.\n */\nexport function isSameActor(\n identity: { userId: string; email?: string },\n actor: ApprovalActorView,\n): boolean {\n return (\n identity.userId === actor.userId ||\n isSameActorEmail(identity.email, actor.email)\n )\n}\n\n/**\n * Reports whether this actor carries an address that names a person.\n *\n * A machine-to-machine token carries none: accounts-backend builds the\n * permissions of such a token from its `permissions` claim alone, and there is\n * no person behind it. See `creatorEmailRequired` of the use case.\n */\nexport function hasActorEmail(actor: ApprovalActorView): boolean {\n return comparableActorEmail(actor.email).length > 0\n}\n\n/**\n * Why an action is not allowed.\n *\n * The code is for a program: control-backend maps it to a status, and the\n * frontend can branch on it. The message is for a person, and it is shown as\n * written.\n */\nexport type ApprovalDenialCode =\n | 'forbidden'\n | 'not-pending'\n | 'self-approval'\n | 'self-rejection'\n | 'already-acknowledged'\n | 'expired'\n | 'not-creator'\n | 'not-executable'\n | 'no-reason'\n | 'too-few-acks'\n | 'no-creator-email'\n\nexport type ApprovalDecision =\n | { allowed: true }\n | { allowed: false; code: ApprovalDenialCode; message: string }\n\nconst ALLOWED: ApprovalDecision = { allowed: true }\n\nfunction deny(code: ApprovalDenialCode, message: string): ApprovalDecision {\n return { allowed: false, code, message }\n}\n\n/**\n * Reports whether the time to answer a request has passed.\n *\n * Only a `pending` request expires. A request that already reached an end state\n * keeps that state, because \"expired\" would lose the answer that was given.\n */\nexport function isExpired(job: ApprovalJobDto, now: Date): boolean {\n return (\n job.status === 'pending' &&\n new Date(job.expiresAt).getTime() <= now.getTime()\n )\n}\n\n/** Reports the status to show, which is `expired` once the time has passed. */\nexport function effectiveStatus(\n job: ApprovalJobDto,\n now: Date,\n): ApprovalJobDto['status'] {\n return isExpired(job, now) ? 'expired' : job.status\n}\n\n/** Reports how many more acknowledgements the request needs. */\nexport function remainingAcks(job: ApprovalJobDto): number {\n return Math.max(0, job.requiredAcks - job.acks.length)\n}\n\n/**\n * Reports whether the actor holds EVERY permission of the set.\n *\n * Every, and not one of them. A payload that raises the guard adds a\n * permission, so \"one of them\" would let the base permission alone carry a\n * request that the extra permission exists to stop.\n *\n * An EMPTY set is held by nobody. That is the opposite of what `Array.every`\n * answers, and it is deliberate: this function guards a privileged action, so\n * the empty case must fail closed. A row with no permissions is not a row that\n * everybody may run \u2014 it is a row that lost its guard, through a migration\n * that did not backfill it or a write that went around the service. Every use\n * case builds a set of at least one, so no real request meets this branch.\n */\nexport function hasEveryPermission(\n actor: ApprovalActorView,\n permissions: string[],\n): boolean {\n if (permissions.length === 0) {\n return false\n }\n return permissions.every((permission) => actor.has(permission))\n}\n\n/**\n * What a person must hold to act on a request.\n *\n * A request carries its own set, because a payload can raise the guard. A use\n * case that never does has a set of one, and it is the base permission.\n */\nexport function requiredPermissionsOf(job: ApprovalJobDto): string[] {\n return job.requiredPermissions\n}\n\n/**\n * Who can READ a request.\n *\n * Two people: one who can act on it, and the one who opened it. A Camundi who\n * holds neither has no reason to read which customer somebody proposed to\n * expire, so the list and the detail both hide it.\n *\n * The rule reads the BASE permission of the use case and not the effective set\n * of the request. A payload that raises the guard raises who can APPROVE, and\n * not who can look: a generation update with the yolo flag is visible to\n * everybody who can approve a plain one, and only the yolo permission runs it.\n * The list filters by use case in SQL, which is what makes one page of rows\n * the same page that a person is allowed to read.\n */\nexport function canView(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n): ApprovalDecision {\n if (\n actor.has(useCase.requiredPermission) ||\n isSameActor(job.createdBy, actor)\n ) {\n return ALLOWED\n }\n return deny('forbidden', 'You cannot see this request.')\n}\n\n/**\n * Who can OPEN a request, and with what.\n *\n * `createPermission` is what keeps the pool that asks as small as the pool that\n * approves. A use case that leaves it absent says the opposite on purpose: the\n * approvals are the guard, and anybody signed in can propose the action.\n *\n * The two checks after it are about the request and not about the person. A\n * count below the floor of the use case, and a missing reason, are both a 400\n * and not a 403, so the words say which one the caller must repair.\n */\nexport function canCreate(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: {\n reason?: string\n requiredAcks?: number\n /**\n * The effective permissions of the payload, from `permissionsFor`.\n *\n * Absent means \"the base permission alone\", which is what most use cases\n * build. Only the permissions ABOVE the base one gate opening a request:\n * see the note in the body.\n */\n requiredPermissions?: string[]\n } = {},\n): ApprovalDecision {\n // The message NAMES the permission. \"You cannot open this request\" is true\n // and useless: a Camundi who reads it cannot tell a missing permission from\n // a rule they never heard of, and the detail page names the permissions of\n // a request in the same way.\n if (useCase.createPermission && !actor.has(useCase.createPermission)) {\n return deny(\n 'forbidden',\n `Opening this request needs ${useCase.createPermission}, and you do not hold it.`,\n )\n }\n // What the PAYLOAD raised, on top of the base permission of the use case.\n // A bulk generation update with the yolo flag needs the yolo permission, and\n // without this check a person who holds the base permission alone could open\n // one. They could not approve it and could not run it, so it is not an\n // escalation \u2014 it is a request that its own author is not allowed to make,\n // waiting in the queue of the people who are.\n //\n // The BASE permission is deliberately not checked here. `createPermission`\n // above is the only gate on the person, and a use case that leaves it absent\n // means that anybody signed in can propose the action. Checking the base\n // permission too would take that choice away from every use case.\n const raised = (request.requiredPermissions ?? []).filter(\n (permission) => permission !== useCase.requiredPermission,\n )\n if (raised.length > 0 && !hasEveryPermission(actor, raised)) {\n return deny(\n 'forbidden',\n `This payload needs ${raised.join(' and ')}, and you do not hold it.`,\n )\n }\n // A request that needs no approval RUNS when it is opened, so the person who\n // opens it must hold what running it needs. Every other use case leaves this\n // to the approvals, which is why the base permission is not checked above.\n // With a floor of zero there is no approval to leave it to.\n const toRun = request.requiredPermissions ?? [useCase.requiredPermission]\n // A service that opens a request of its own use case holds the\n // `createPermission` checked above, and that is the guard of the run. Without\n // a `createPermission` there is no such guard, so the flag lifts nothing.\n const openedBySystem = useCase.openedBySystem && !!useCase.createPermission\n if (\n resolveRequiredAcks(useCase, request.requiredAcks) === 0 &&\n !useCase.openToEveryone &&\n !openedBySystem &&\n !hasEveryPermission(actor, toRun)\n ) {\n // The message NAMES the permission, and it says why opening needs it. A\n // Camundi who reads \"you cannot open this request\" for an action that\n // needs no approval cannot tell which of the two rules refused them.\n return deny(\n 'forbidden',\n `This request needs no approval, so opening it runs it. That needs ` +\n `${toRun.join(' and ')}, and you do not hold it.`,\n )\n }\n // The action of this use case carries the address of the person who asked,\n // so a caller with no address must be refused HERE. The alternative is a\n // request that looks ordinary, collects its approvals over days, and then\n // fails because the other service cannot find a user called \"unknown\".\n if (useCase.creatorEmailRequired && !hasActorEmail(actor)) {\n return deny(\n 'no-creator-email',\n 'This action needs the email address of the person who asks for it, ' +\n 'and your session carries none. Sign in as a person to open this ' +\n 'request.',\n )\n }\n if (useCase.reasonRequired && (request.reason ?? '').trim().length === 0) {\n return deny('no-reason', 'Give a reason to open this request.')\n }\n if (\n request.requiredAcks !== undefined &&\n request.requiredAcks < useCase.minAcks\n ) {\n return deny(\n 'too-few-acks',\n `This action needs at least ${useCase.minAcks} approvals.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * The approvals that a new request asks for.\n *\n * The caller names a number or names none. A number below the floor is refused\n * by `canCreate`, and this function holds the answer inside the range anyway:\n * the two run in the same call, and a floor that only one of them knows is a\n * floor that a later caller can step over.\n */\nexport function resolveRequiredAcks(\n useCase: ApprovalUseCaseDto,\n requested?: number,\n): number {\n if (requested === undefined) {\n return Math.max(useCase.defaultAcks, useCase.minAcks)\n }\n return Math.max(Math.trunc(requested), useCase.minAcks)\n}\n\n/**\n * The four-eyes rule.\n *\n * The bans are in the order a person meets them, and each one has its own\n * message: \"you already approved this\" and \"you cannot approve your own\n * request\" are different mistakes, and one message for both helps nobody.\n *\n * Each of the two bans on a person reads BOTH identities: the `sub` of the\n * token, and the email. See `ApprovalActorView.email` for why one of them is\n * not enough.\n */\nexport function canAcknowledge(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot approve this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be approved.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be approved.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-approval',\n 'You opened this request, so you cannot approve it.',\n )\n }\n if (job.acks.some((ack) => isSameActor(ack, actor))) {\n return deny('already-acknowledged', 'You already approved this request.')\n }\n return ALLOWED\n}\n\n/**\n * A rejection needs the same permission as an acknowledgement, and a reason.\n *\n * Two people answer a request, and each one has ONE verb. Somebody else\n * approves or rejects it; the person who opened it withdraws it. The creator is\n * refused here for the same reason `canAcknowledge` refuses them: one person\n * must not be both sides of a four-eyes decision. The message names the button\n * that they do have, and the trail loses nothing, because a withdrawal writes\n * its reason into the same field.\n *\n * The ORDER of the checks is the order that a person meets them, and the state\n * of the request comes before the reason. A closed request must answer\n * \"already closed\" whatever the caller typed: with the reason first, a page\n * that had no reason yet read `no-reason` from a rejected request, and the\n * Reject button of control-frontend stayed enabled on a request that nothing\n * could reject any more.\n */\nexport function canReject(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n reason: string,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot reject this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be rejected.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be rejected.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-rejection',\n 'You opened this request, so withdraw it instead of rejecting it.',\n )\n }\n if (reason.trim().length === 0) {\n return deny('no-reason', 'Give a reason to reject this request.')\n }\n return ALLOWED\n}\n\n/**\n * Only the person who opened a request can take it back.\n *\n * BOTH identities, as every other rule of this file reads them. One person can\n * hold two Auth0 identities with two different `sub` values, and a rule that\n * read the id alone refused the creator their own withdrawal as soon as the\n * browser and the service resolved that id from two different tokens.\n */\nexport function canWithdraw(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!isSameActor(job.createdBy, actor)) {\n return deny(\n 'not-creator',\n 'Only the person who opened a request can withdraw it.',\n )\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be withdrawn.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be withdrawn.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * Runs a request that has its approvals.\n *\n * This is the button of the explicit mode. `failed` is executable again,\n * because a failure is often a problem of the system that is now repaired, and\n * asking for the approvals a second time helps nobody.\n *\n * ANY holder of the permission may press it, and not only the person who\n * opened the request. The decision was made when the request reached\n * `approved`: `canAcknowledge` refuses the creator, so the approvals on the\n * row came from other people, and the button only carries out what they\n * already agreed to. Holding the button to the creator would let one Camundi\n * on holiday freeze an approved action, and a use case that must not run\n * without its author has `executionMode: 'auto'` instead.\n *\n * Who PRESSED it and who ASKED for it are two different facts, and both are\n * kept: the audit trail of control-backend records the actor of the run, and\n * an action that names a person to another service names the creator. See\n * `onBehalfOf` of `extend-trial`.\n */\nexport function canExecute(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n if (job.status !== 'approved' && job.status !== 'failed') {\n return deny(\n 'not-executable',\n `This request is ${job.status} and cannot be run.`,\n )\n }\n return ALLOWED\n}\n"],
|
|
5
|
-
"mappings": "AAMA,SAAS,2BAA2B;AAKpC,SAAS,0BAA0B;AAoD5B,SAAS,wBACd,MACiC;AACjC,QAAM,MAAM,KAAK,IAAI,mBAAmB,uBAAuB;AAC/D,SAAO,CAAC,eAAe,OAAO,KAAK,IAAI,UAAU;AACnD;AAaO,SAAS,iBAAiB,KAAc,OAAyB;AACtE,QAAM,OAAO,qBAAqB,GAAG;AACrC,SAAO,KAAK,SAAS,KAAK,SAAS,qBAAqB,KAAK;AAC/D;AASA,SAAS,qBAAqB,OAAwB;AACpD,QAAM,UAAU,OAAO,KAAK,EAAE,YAAY,KAAK;AAC/C,SAAO,YAAY,sBAAsB,KAAK;AAChD;AAWO,SAAS,YACd,UACA,OACS;AACT,SACE,SAAS,WAAW,MAAM,UAC1B,iBAAiB,SAAS,OAAO,MAAM,KAAK;AAEhD;AASO,SAAS,cAAc,OAAmC;AAC/D,SAAO,qBAAqB,MAAM,KAAK,EAAE,SAAS;AACpD;AA0BA,MAAM,UAA4B,EAAE,SAAS,KAAK;AAElD,SAAS,KAAK,MAA0B,SAAmC;AACzE,SAAO,EAAE,SAAS,OAAO,MAAM,QAAQ;AACzC;AAQO,SAAS,UAAU,KAAqB,KAAoB;AACjE,SACE,IAAI,WAAW,aACf,IAAI,KAAK,IAAI,SAAS,EAAE,QAAQ,KAAK,IAAI,QAAQ;AAErD;
|
|
4
|
+
"sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { UNKNOWN_ACTOR_EMAIL } from '../constants/misc.const.js'\nimport type {\n ApprovalJobDto,\n ApprovalUseCaseDto,\n} from '../dtos/ApprovalJob.dto.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\n\n/**\n * The rules of the approval mechanism, as pure functions.\n *\n * Three consumers run this file: control-backend decides whether to reject a\n * request, control-frontend decides whether to disable a button, and cloudbot\n * decides whether to answer. One copy means the button and the endpoint can\n * never disagree.\n *\n * Two choices in the signatures carry the design:\n *\n * - The actor is an interface with a `has` method. The backend holds an array\n * of Auth0 claims, and `useSession()` in the frontend gives a flat record of\n * booleans. Neither side has to convert to the shape of the other.\n * - `now` is a parameter. The expiry is lazy, so a stored `pending` request can\n * be expired already. A parameter makes a difference between the two clocks\n * an input that a test can set, and not a silent disagreement.\n *\n * No function here does I/O or returns a promise. The browser call site is what\n * enforces that, and it also stops a later use case from turning a list of 50\n * rows into 200 network calls.\n */\n\n/** Who is asking. See the note above on why this is an interface. */\nexport interface ApprovalActorView {\n userId: string\n /**\n * The email of the actor. It is the SECOND identity of the four-eyes rule.\n *\n * `userId` alone is not enough. One person can hold more than one identity in\n * Auth0 \u2014 a connection through SSO and a connection through a database, or an\n * account that was made again \u2014 and each one carries a different `sub`. The\n * same human then opens a request with one identity and approves it with the\n * other, which is the rule defeated.\n *\n * It is optional because a token can carry no email. See\n * `isSameActorEmail` for what that means for the comparison.\n */\n email?: string\n has(permission: string): boolean\n}\n\n/**\n * Builds the `has` of an actor from the permissions that the actor holds.\n *\n * `ConsolePermissions.APPROVALS_ALL_USE_CASES` stands in for every permission\n * that a use case asks for. Both actor builders call this function, the one of\n * control-backend and the one of the browser, so the button and the route\n * agree on it. A rule that reads `actor.has` needs no change for it, and nor\n * does a use case that a later change adds.\n */\nexport function approvalPermissionCheck(\n held: ReadonlySet<string>,\n): (permission: string) => boolean {\n const all = held.has(ConsolePermissions.APPROVALS_ALL_USE_CASES)\n return (permission) => all || held.has(permission)\n}\n\n/**\n * Reports whether two email addresses name one person.\n *\n * Two values that are absent, empty, or `UNKNOWN_ACTOR_EMAIL` are NOT one\n * person. A token with no email would otherwise make every actor equal to\n * every other actor, and the rule would refuse everybody.\n *\n * The comparison removes the spaces and the case. Auth0 gives the address as\n * the identity provider wrote it, so `A.B@camunda.com` and `a.b@camunda.com`\n * are one mailbox and must not pass as two people.\n */\nexport function isSameActorEmail(one?: string, other?: string): boolean {\n const left = comparableActorEmail(one)\n return left.length > 0 && left === comparableActorEmail(other)\n}\n\n/**\n * The address in the one form that a comparison may use.\n *\n * An absent address, an empty one and `UNKNOWN_ACTOR_EMAIL` all become an\n * empty string, because none of the three names a person. The spaces and the\n * case go as well: Auth0 gives the address as the identity provider wrote it.\n */\nfunction comparableActorEmail(value?: string): string {\n const trimmed = value?.trim().toLowerCase() ?? ''\n return trimmed === UNKNOWN_ACTOR_EMAIL ? '' : trimmed\n}\n\n/**\n * Reports whether one identity of a request names the actor.\n *\n * The `sub` of the token OR the email, because one person can hold more than\n * one Auth0 identity. Every rule that asks \"is this the same human?\" reads both\n * through this function, so a rule cannot forget the second half: `canWithdraw`\n * did, and it refused a creator whose browser and whose service resolved the\n * id from two different tokens.\n */\nexport function isSameActor(\n identity: { userId: string; email?: string },\n actor: ApprovalActorView,\n): boolean {\n return (\n identity.userId === actor.userId ||\n isSameActorEmail(identity.email, actor.email)\n )\n}\n\n/**\n * Reports whether this actor carries an address that names a person.\n *\n * A machine-to-machine token carries none: accounts-backend builds the\n * permissions of such a token from its `permissions` claim alone, and there is\n * no person behind it. See `creatorEmailRequired` of the use case.\n */\nexport function hasActorEmail(actor: ApprovalActorView): boolean {\n return comparableActorEmail(actor.email).length > 0\n}\n\n/**\n * Why an action is not allowed.\n *\n * The code is for a program: control-backend maps it to a status, and the\n * frontend can branch on it. The message is for a person, and it is shown as\n * written.\n */\nexport type ApprovalDenialCode =\n | 'forbidden'\n | 'not-pending'\n | 'self-approval'\n | 'self-rejection'\n | 'already-acknowledged'\n | 'expired'\n | 'not-creator'\n | 'not-executable'\n | 'no-reason'\n | 'too-few-acks'\n | 'no-creator-email'\n\nexport type ApprovalDecision =\n | { allowed: true }\n | { allowed: false; code: ApprovalDenialCode; message: string }\n\nconst ALLOWED: ApprovalDecision = { allowed: true }\n\nfunction deny(code: ApprovalDenialCode, message: string): ApprovalDecision {\n return { allowed: false, code, message }\n}\n\n/**\n * Reports whether the time to answer a request has passed.\n *\n * Only a `pending` request expires. A request that already reached an end state\n * keeps that state, because \"expired\" would lose the answer that was given.\n */\nexport function isExpired(job: ApprovalJobDto, now: Date): boolean {\n return (\n job.status === 'pending' &&\n new Date(job.expiresAt).getTime() <= now.getTime()\n )\n}\n\n/**\n * Reports the status to show. It is `expired` once the time to answer has\n * passed, or once the run window of an `approved` or `failed` request has\n * ended. control-backend writes both, and this shows them before the write.\n */\nexport function effectiveStatus(\n job: ApprovalJobDto,\n now: Date,\n): ApprovalJobDto['status'] {\n return isExpired(job, now) || isRunWindowClosed(job, now)\n ? 'expired'\n : job.status\n}\n\n/** Reports how many more acknowledgements the request needs. */\nexport function remainingAcks(job: ApprovalJobDto): number {\n return Math.max(0, job.requiredAcks - job.acks.length)\n}\n\n/**\n * Reports whether the actor holds EVERY permission of the set.\n *\n * Every, and not one of them. A payload that raises the guard adds a\n * permission, so \"one of them\" would let the base permission alone carry a\n * request that the extra permission exists to stop.\n *\n * An EMPTY set is held by nobody. That is the opposite of what `Array.every`\n * answers, and it is deliberate: this function guards a privileged action, so\n * the empty case must fail closed. A row with no permissions is not a row that\n * everybody may run \u2014 it is a row that lost its guard, through a migration\n * that did not backfill it or a write that went around the service. Every use\n * case builds a set of at least one, so no real request meets this branch.\n */\nexport function hasEveryPermission(\n actor: ApprovalActorView,\n permissions: string[],\n): boolean {\n if (permissions.length === 0) {\n return false\n }\n return permissions.every((permission) => actor.has(permission))\n}\n\n/**\n * What a person must hold to act on a request.\n *\n * A request carries its own set, because a payload can raise the guard. A use\n * case that never does has a set of one, and it is the base permission.\n */\nexport function requiredPermissionsOf(job: ApprovalJobDto): string[] {\n return job.requiredPermissions\n}\n\n/**\n * Who can READ a request.\n *\n * Two people: one who can act on it, and the one who opened it. A Camundi who\n * holds neither has no reason to read which customer somebody proposed to\n * expire, so the list and the detail both hide it.\n *\n * The rule reads the BASE permission of the use case and not the effective set\n * of the request. A payload that raises the guard raises who can APPROVE, and\n * not who can look: a generation update with the yolo flag is visible to\n * everybody who can approve a plain one, and only the yolo permission runs it.\n * The list filters by use case in SQL, which is what makes one page of rows\n * the same page that a person is allowed to read.\n */\nexport function canView(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n): ApprovalDecision {\n if (\n actor.has(useCase.requiredPermission) ||\n isSameActor(job.createdBy, actor)\n ) {\n return ALLOWED\n }\n return deny('forbidden', 'You cannot see this request.')\n}\n\n/**\n * Who can OPEN a request, and with what.\n *\n * `createPermission` is what keeps the pool that asks as small as the pool that\n * approves. A use case that leaves it absent says the opposite on purpose: the\n * approvals are the guard, and anybody signed in can propose the action.\n *\n * The two checks after it are about the request and not about the person. A\n * count below the floor of the use case, and a missing reason, are both a 400\n * and not a 403, so the words say which one the caller must repair.\n */\nexport function canCreate(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: {\n reason?: string\n requiredAcks?: number\n /**\n * The effective permissions of the payload, from `permissionsFor`.\n *\n * Absent means \"the base permission alone\", which is what most use cases\n * build. Only the permissions ABOVE the base one gate opening a request:\n * see the note in the body.\n */\n requiredPermissions?: string[]\n } = {},\n): ApprovalDecision {\n // The message NAMES the permission. \"You cannot open this request\" is true\n // and useless: a Camundi who reads it cannot tell a missing permission from\n // a rule they never heard of, and the detail page names the permissions of\n // a request in the same way.\n if (useCase.createPermission && !actor.has(useCase.createPermission)) {\n return deny(\n 'forbidden',\n `Opening this request needs ${useCase.createPermission}, and you do not hold it.`,\n )\n }\n // What the PAYLOAD raised, on top of the base permission of the use case.\n // A bulk generation update with the yolo flag needs the yolo permission, and\n // without this check a person who holds the base permission alone could open\n // one. They could not approve it and could not run it, so it is not an\n // escalation \u2014 it is a request that its own author is not allowed to make,\n // waiting in the queue of the people who are.\n //\n // The BASE permission is deliberately not checked here. `createPermission`\n // above is the only gate on the person, and a use case that leaves it absent\n // means that anybody signed in can propose the action. Checking the base\n // permission too would take that choice away from every use case.\n const raised = (request.requiredPermissions ?? []).filter(\n (permission) => permission !== useCase.requiredPermission,\n )\n if (raised.length > 0 && !hasEveryPermission(actor, raised)) {\n return deny(\n 'forbidden',\n `This payload needs ${raised.join(' and ')}, and you do not hold it.`,\n )\n }\n // A request that needs no approval RUNS when it is opened, so the person who\n // opens it must hold what running it needs. Every other use case leaves this\n // to the approvals, which is why the base permission is not checked above.\n // With a floor of zero there is no approval to leave it to.\n const toRun = request.requiredPermissions ?? [useCase.requiredPermission]\n // A service that opens a request of its own use case holds the\n // `createPermission` checked above, and that is the guard of the run. Without\n // a `createPermission` there is no such guard, so the flag lifts nothing.\n const openedBySystem = useCase.openedBySystem && !!useCase.createPermission\n if (\n resolveRequiredAcks(useCase, request.requiredAcks) === 0 &&\n !useCase.openToEveryone &&\n !openedBySystem &&\n !hasEveryPermission(actor, toRun)\n ) {\n // The message NAMES the permission, and it says why opening needs it. A\n // Camundi who reads \"you cannot open this request\" for an action that\n // needs no approval cannot tell which of the two rules refused them.\n return deny(\n 'forbidden',\n `This request needs no approval, so opening it runs it. That needs ` +\n `${toRun.join(' and ')}, and you do not hold it.`,\n )\n }\n // The action of this use case carries the address of the person who asked,\n // so a caller with no address must be refused HERE. The alternative is a\n // request that looks ordinary, collects its approvals over days, and then\n // fails because the other service cannot find a user called \"unknown\".\n if (useCase.creatorEmailRequired && !hasActorEmail(actor)) {\n return deny(\n 'no-creator-email',\n 'This action needs the email address of the person who asks for it, ' +\n 'and your session carries none. Sign in as a person to open this ' +\n 'request.',\n )\n }\n if (useCase.reasonRequired && (request.reason ?? '').trim().length === 0) {\n return deny('no-reason', 'Give a reason to open this request.')\n }\n if (\n request.requiredAcks !== undefined &&\n request.requiredAcks < useCase.minAcks\n ) {\n return deny(\n 'too-few-acks',\n `This action needs at least ${useCase.minAcks} approvals.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * The approvals that a new request asks for.\n *\n * The caller names a number or names none. A number below the floor is refused\n * by `canCreate`, and this function holds the answer inside the range anyway:\n * the two run in the same call, and a floor that only one of them knows is a\n * floor that a later caller can step over.\n */\nexport function resolveRequiredAcks(\n useCase: ApprovalUseCaseDto,\n requested?: number,\n): number {\n if (requested === undefined) {\n return Math.max(useCase.defaultAcks, useCase.minAcks)\n }\n return Math.max(Math.trunc(requested), useCase.minAcks)\n}\n\n/**\n * The four-eyes rule.\n *\n * The bans are in the order a person meets them, and each one has its own\n * message: \"you already approved this\" and \"you cannot approve your own\n * request\" are different mistakes, and one message for both helps nobody.\n *\n * Each of the two bans on a person reads BOTH identities: the `sub` of the\n * token, and the email. See `ApprovalActorView.email` for why one of them is\n * not enough.\n */\nexport function canAcknowledge(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot approve this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be approved.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be approved.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-approval',\n 'You opened this request, so you cannot approve it.',\n )\n }\n if (job.acks.some((ack) => isSameActor(ack, actor))) {\n return deny('already-acknowledged', 'You already approved this request.')\n }\n return ALLOWED\n}\n\n/**\n * A rejection needs the same permission as an acknowledgement, and a reason.\n *\n * Two people answer a request, and each one has ONE verb. Somebody else\n * approves or rejects it; the person who opened it withdraws it. The creator is\n * refused here for the same reason `canAcknowledge` refuses them: one person\n * must not be both sides of a four-eyes decision. The message names the button\n * that they do have, and the trail loses nothing, because a withdrawal writes\n * its reason into the same field.\n *\n * The ORDER of the checks is the order that a person meets them, and the state\n * of the request comes before the reason. A closed request must answer\n * \"already closed\" whatever the caller typed: with the reason first, a page\n * that had no reason yet read `no-reason` from a rejected request, and the\n * Reject button of control-frontend stayed enabled on a request that nothing\n * could reject any more.\n */\nexport function canReject(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n reason: string,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot reject this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be rejected.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be rejected.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-rejection',\n 'You opened this request, so withdraw it instead of rejecting it.',\n )\n }\n if (reason.trim().length === 0) {\n return deny('no-reason', 'Give a reason to reject this request.')\n }\n return ALLOWED\n}\n\n/**\n * Only the person who opened a request can take it back.\n *\n * BOTH identities, as every other rule of this file reads them. One person can\n * hold two Auth0 identities with two different `sub` values, and a rule that\n * read the id alone refused the creator their own withdrawal as soon as the\n * browser and the service resolved that id from two different tokens.\n */\nexport function canWithdraw(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!isSameActor(job.createdBy, actor)) {\n return deny(\n 'not-creator',\n 'Only the person who opened a request can withdraw it.',\n )\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be withdrawn.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be withdrawn.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * Runs a request that has its approvals.\n *\n * This is the button of the explicit mode. `failed` is executable again,\n * because a failure is often a problem of the system that is now repaired, and\n * asking for the approvals a second time helps nobody. Both run only inside\n * the run window, see `EXECUTE_WINDOW_DAYS`.\n *\n * ANY holder of the permission may press it, and not only the person who\n * opened the request. The decision was made when the request reached\n * `approved`: `canAcknowledge` refuses the creator, so the approvals on the\n * row came from other people, and the button only carries out what they\n * already agreed to. Holding the button to the creator would let one Camundi\n * on holiday freeze an approved action, and a use case that must not run\n * without its author has `executionMode: 'auto'` instead.\n *\n * Who PRESSED it and who ASKED for it are two different facts, and both are\n * kept: the audit trail of control-backend records the actor of the run, and\n * an action that names a person to another service names the creator. See\n * `onBehalfOf` of `extend-trial`.\n */\nexport function canExecute(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n // A default, because the package is published and a caller from before\n // the run window passes two arguments.\n now: Date = new Date(),\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n // control-backend writes `expired` before it answers a read, so the row\n // that a person sees is usually already `expired` and not past its window.\n if (job.status === 'expired') {\n return deny('expired', 'This request is expired. Open a new request.')\n }\n if (job.status !== 'approved' && job.status !== 'failed') {\n return deny(\n 'not-executable',\n `This request is ${job.status} and cannot be run.`,\n )\n }\n if (isRunWindowClosed(job, now)) {\n return deny(\n 'expired',\n `The approvals of this request are older than ${EXECUTE_WINDOW_DAYS} days. Open a new request.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * How many days a request can run after its last approval.\n *\n * The approvers agreed to the request as the world was on that day. Later the\n * facts can change: an organization in trial becomes a paying customer. A\n * request that is older than this window needs new approvals. The rule is here\n * and not in each use case, so a new use case cannot forget it.\n *\n * A `failed` request counts from the same day. A retry is for a problem of the\n * system that is now repaired, not for a run weeks later.\n */\nexport const EXECUTE_WINDOW_DAYS = 7\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\n/**\n * Reports the end of the run window of a request.\n *\n * The window starts at the last approval. A use case that needs zero\n * approvals has none, so its window starts when the request was opened.\n */\nexport function runWindowEnd(job: ApprovalJobDto): Date {\n const start = job.acks.reduce(\n (latest, ack) => Math.max(latest, new Date(ack.date).getTime()),\n new Date(job.createdAt).getTime(),\n )\n return new Date(start + EXECUTE_WINDOW_DAYS * DAY_MS)\n}\n\n/**\n * Reports whether an `approved` or `failed` request is past its run window.\n *\n * It fails closed: a date that does not parse gives NaN, and NaN closes the\n * window. The window guards a privileged action.\n */\nexport function isRunWindowClosed(job: ApprovalJobDto, now: Date): boolean {\n if (job.status !== 'approved' && job.status !== 'failed') {\n return false\n }\n return !(runWindowEnd(job).getTime() > now.getTime())\n}\n\n/**\n * Reports whether a request ran, failed, and will not run again by itself.\n *\n * That is a `failed` request, or a failed request that control-backend then\n * wrote as `expired` at the end of its run window. Only a run raises\n * `attempts`, and an `executed` request never expires.\n */\nexport function hasFailedRun(job: ApprovalJobDto): boolean {\n return (\n job.status === 'failed' || (job.status === 'expired' && job.attempts > 0)\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -47,6 +47,7 @@ export declare const APPROVAL_USE_CASE_TYPES: {
|
|
|
47
47
|
readonly onboardToEnterprise: "onboard-to-enterprise";
|
|
48
48
|
readonly changeClusterPlanType: "change-cluster-plan-type";
|
|
49
49
|
readonly clusterTtlDeletion: "cluster-ttl-deletion";
|
|
50
|
+
readonly deleteClusterCorpses: "delete-cluster-corpses";
|
|
50
51
|
readonly forceDeleteClusterPlanType: "force-delete-cluster-plan-type";
|
|
51
52
|
};
|
|
52
53
|
/**
|