@camunda/ccma-shared-types 10.14.0 → 11.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/dist/approvals/policy.d.ts +6 -0
- package/dist/approvals/policy.js +12 -3
- package/dist/approvals/policy.js.map +2 -2
- package/dist/config/camundi-permission-rules.js +8 -2
- package/dist/config/camundi-permission-rules.js.map +2 -2
- package/dist/dtos/ApprovalJob.dto.d.ts +4 -2
- package/dist/dtos/ApprovalJob.dto.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -0
- package/dist/index.js.map +2 -2
- package/dist/permissions/accounts.permissions.d.ts +8 -0
- package/dist/permissions/accounts.permissions.js +11 -1
- package/dist/permissions/accounts.permissions.js.map +2 -2
- package/dist/permissions/combo.js +3 -0
- package/dist/permissions/combo.js.map +2 -2
- package/dist/types/Roles.d.ts +1 -1
- package/dist/types/Roles.js.map +2 -2
- package/dist/types/hdp.types.d.ts +1 -16
- package/dist/types/hdp.types.js.map +2 -2
- package/package.json +1 -1
|
@@ -168,6 +168,12 @@ export declare function overrideGranteeOf(job: Pick<ApprovalJobDto, 'type' | 'pa
|
|
|
168
168
|
* every call. A token never carries it.
|
|
169
169
|
*/
|
|
170
170
|
export declare function isOverrideGrantActive(grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined, now: Date): boolean;
|
|
171
|
+
/**
|
|
172
|
+
* The permissions that open a request of this use case. Any one of them is
|
|
173
|
+
* enough. Returns `[]` when the field is absent or empty. `canCreate` tells
|
|
174
|
+
* them apart: absent opens to anybody signed in, empty to nobody.
|
|
175
|
+
*/
|
|
176
|
+
export declare function createPermissionsOf(useCase: Pick<ApprovalUseCaseDto, 'createPermission'>): string[];
|
|
171
177
|
/**
|
|
172
178
|
* Who can OPEN a request, and with what.
|
|
173
179
|
*
|
package/dist/approvals/policy.js
CHANGED
|
@@ -73,11 +73,19 @@ function isOverrideGrantActive(grant, now) {
|
|
|
73
73
|
}
|
|
74
74
|
return new Date(grant.expiresAt).getTime() > now.getTime();
|
|
75
75
|
}
|
|
76
|
+
function createPermissionsOf(useCase) {
|
|
77
|
+
const { createPermission } = useCase;
|
|
78
|
+
if (createPermission === void 0) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return typeof createPermission === "string" ? [createPermission] : [...createPermission];
|
|
82
|
+
}
|
|
76
83
|
function canCreate(actor, useCase, request = {}) {
|
|
77
|
-
|
|
84
|
+
const createPermissions = createPermissionsOf(useCase);
|
|
85
|
+
if (useCase.createPermission !== void 0 && !createPermissions.some((permission) => actor.has(permission))) {
|
|
78
86
|
return deny(
|
|
79
87
|
"forbidden",
|
|
80
|
-
`Opening this request needs ${
|
|
88
|
+
createPermissions.length === 0 ? "Nobody can open this request, because its use case lists no permission." : `Opening this request needs ${createPermissions.join(" or ")}, and ${createPermissions.length > 1 ? "you hold none of them" : "you do not hold it"}.`
|
|
81
89
|
);
|
|
82
90
|
}
|
|
83
91
|
const raised = (request.requiredPermissions ?? []).filter(
|
|
@@ -90,7 +98,7 @@ function canCreate(actor, useCase, request = {}) {
|
|
|
90
98
|
);
|
|
91
99
|
}
|
|
92
100
|
const toRun = request.requiredPermissions ?? [useCase.requiredPermission];
|
|
93
|
-
const openedBySystem = useCase.openedBySystem &&
|
|
101
|
+
const openedBySystem = useCase.openedBySystem && createPermissions.length > 0;
|
|
94
102
|
if (resolveRequiredAcks(useCase, request.requiredAcks) === 0 && !useCase.openToEveryone && !openedBySystem && !hasEveryPermission(actor, toRun)) {
|
|
95
103
|
return deny(
|
|
96
104
|
"forbidden",
|
|
@@ -331,6 +339,7 @@ export {
|
|
|
331
339
|
canRunOverridden,
|
|
332
340
|
canView,
|
|
333
341
|
canWithdraw,
|
|
342
|
+
createPermissionsOf,
|
|
334
343
|
effectiveStatus,
|
|
335
344
|
hasActorEmail,
|
|
336
345
|
hasEveryPermission,
|
|
@@ -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 ApprovalOverrideGrantDto,\n ApprovalUseCaseDto,\n} from '../dtos/ApprovalJob.dto.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\nimport {\n APPROVAL_USE_CASE_TYPES,\n isApprovalOverridable,\n} from './use-cases.const.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 | 'no-override-grant'\n | 'not-overridable'\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 isOverrideGrantee(job, actor)\n ) {\n return ALLOWED\n }\n return deny('forbidden', 'You cannot see this request.')\n}\n\n/**\n * The person that a request of the two override use cases names, or\n * `undefined` for every other request.\n *\n * Four rules read it. The grantee cannot approve their own grant, also not\n * when they open it (`opensWithOwnApproval`), the grantee can read both\n * requests, and the grantee can end their own grant before its time.\n */\nexport function overrideGranteeOf(\n job: Pick<ApprovalJobDto, 'type' | 'payload'>,\n): { userId: string; email?: string } | undefined {\n if (\n job.type !== APPROVAL_USE_CASE_TYPES.grantApprovalOverride &&\n job.type !== APPROVAL_USE_CASE_TYPES.revokeApprovalOverride\n ) {\n return undefined\n }\n const { granteeUserId, granteeEmail } = job.payload\n if (typeof granteeUserId !== 'string' && typeof granteeEmail !== 'string') {\n return undefined\n }\n return {\n userId: typeof granteeUserId === 'string' ? granteeUserId : '',\n email: typeof granteeEmail === 'string' ? granteeEmail : undefined,\n }\n}\n\nfunction isOverrideGrantee(\n job: Pick<ApprovalJobDto, 'type' | 'payload'>,\n actor: ApprovalActorView,\n): boolean {\n const grantee = overrideGranteeOf(job)\n return grantee !== undefined && isSameActor(grantee, actor)\n}\n\n/**\n * Reports whether an approval override grant lets its holder act now.\n *\n * It fails closed: an absent grant, a revoked one, and a date that does not\n * parse are all inactive. control-backend reads the grant from its table at\n * every call. A token never carries it.\n */\nexport function isOverrideGrantActive(\n grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined,\n now: Date,\n): boolean {\n if (!grant || grant.revokedAt) {\n return false\n }\n return new Date(grant.expiresAt).getTime() > now.getTime()\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 (\n needsReason(actor, useCase, {\n requiredAcks: request.requiredAcks,\n requiredPermissions: toRun,\n }) &&\n (request.reason ?? '').trim().length === 0\n ) {\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 * Reports whether the opener approves a new request when it is created.\n *\n * The opener approves when the request needs an approval and the opener holds\n * every run permission of it. `APPROVAL_RULES` counts this approval. A request\n * that needs no approval gets none, so its `approvedBy` stays empty.\n *\n * When `request` is given, the grantee of an approval override grant does not\n * approve the grant that they open for themselves, as `canAcknowledge`\n * refuses their approval later. Every approval of such a request then comes\n * from another person.\n */\nexport function opensWithOwnApproval(\n actor: ApprovalActorView,\n requiredAcks: number,\n requiredPermissions: string[],\n request?: Pick<ApprovalJobDto, 'type' | 'payload'>,\n): boolean {\n if (request !== undefined && isOverrideGrantee(request, actor)) {\n return false\n }\n return requiredAcks > 0 && hasEveryPermission(actor, requiredPermissions)\n}\n\n/**\n * Reports whether a new request needs a reason.\n *\n * A request needs no reason when its opener holds every run permission and\n * needs no second person. Nobody else reads the reason before the action\n * runs, and the audit record names the opener.\n */\nexport function needsReason(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: { requiredAcks?: number; requiredPermissions?: string[] } = {},\n): boolean {\n if (!useCase.reasonRequired) {\n return false\n }\n return !(\n resolveRequiredAcks(useCase, request.requiredAcks) <= 1 &&\n hasEveryPermission(\n actor,\n request.requiredPermissions ?? [useCase.requiredPermission],\n )\n )\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 (isOverrideGrantee(job, actor)) {\n return deny(\n 'self-approval',\n 'This request gives you an override grant, 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`, when the row got all the approvals that the request asks for.\n * One of them can be the approval of the opener at create (see\n * `opensWithOwnApproval`), and `canAcknowledge` keeps each person to one\n * approval. The button only carries out what these people already agreed to.\n * 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 // The grantee may end their own grant early, with no permission. Ending a\n // grant takes power away, so it needs no second person.\n // A `failed` revoke counts too: the reconciler does not run a failed row\n // again, so the grantee must be able to retry it.\n const endsOwnGrant =\n (job.status === 'scheduled' || job.status === 'failed') &&\n job.type === APPROVAL_USE_CASE_TYPES.revokeApprovalOverride &&\n isOverrideGrantee(job, actor)\n if (!endsOwnGrant && !hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n // The grantee cannot run the request that gives them their grant, as\n // `canAcknowledge` refuses their approval. A retry of a `failed` grant would\n // let them pick the time when the grant starts.\n if (\n job.type === APPROVAL_USE_CASE_TYPES.grantApprovalOverride &&\n isOverrideGrantee(job, actor)\n ) {\n return deny(\n 'self-approval',\n 'This request gives you an override grant, so you cannot run it.',\n )\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 // A `scheduled` request runs by itself at `runAt`. To run it before that\n // time needs the same permissions as any other run.\n if (\n job.status !== 'approved' &&\n job.status !== 'failed' &&\n job.status !== 'scheduled'\n ) {\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 * Why a request that an override approved can no longer run: the grant that\n * the override used ended. The revoke of a grant writes it as `closedReason`\n * on each such request, and `canRunOverridden` answers it.\n */\nexport const OVERRIDE_GRANT_ENDED_MESSAGE =\n 'The approval override grant that ran this request ended. Open a new request.'\n\n/**\n * Reports whether a request that an override ran can still run.\n *\n * An override of a request in the explicit mode moves it to `approved`, and a\n * person runs it later. A failed run can also run again. The grant is the only\n * thing that stood in for the approvals, so each run needs it to be active\n * still: a revoke or the end of the grant stops the request too.\n *\n * `grant` is the grant that the override used (`job.overrideGrantId`), which\n * the caller reads from the grant table at the run. A later grant of the same\n * person does not count: a revoke ends the requests that its grant ran, and a\n * new grant must not bring them back. A request with no override is not\n * affected. A type that joined `APPROVAL_OVERRIDE_DENYLIST` after the\n * override needs its approvals, as `canOverride` says.\n */\nexport function canRunOverridden(\n job: Pick<ApprovalJobDto, 'type' | 'overriddenBy'>,\n grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined,\n now: Date,\n): ApprovalDecision {\n if (!job.overriddenBy) {\n return ALLOWED\n }\n if (!isApprovalOverridable(job.type)) {\n return deny(\n 'not-overridable',\n 'An override cannot run this type of request. It needs its approvals.',\n )\n }\n if (isOverrideGrantActive(grant, now)) {\n return ALLOWED\n }\n return deny('no-override-grant', OVERRIDE_GRANT_ENDED_MESSAGE)\n}\n\n/**\n * Runs a `pending` request with an approval override grant, in place of the\n * approvals that it still needs.\n *\n * `grant` is the active grant of the actor. The caller reads it from the\n * grant table at every call, and never from a token: a token keeps its\n * permissions until it expires, and a revoked grant must stop at once.\n *\n * The creator may override their own request, because that is what a grant\n * is for. The actor still needs the permissions of the request: a grant\n * takes the place of the second person, and not of the permission. A use case\n * in `APPROVAL_OVERRIDE_DENYLIST` always needs its approvals.\n */\nexport function canOverride(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n args: {\n grant?: Pick<\n ApprovalOverrideGrantDto,\n 'userId' | 'email' | 'expiresAt' | 'revokedAt'\n >\n reason: string\n },\n): ApprovalDecision {\n if (\n !args.grant ||\n !isOverrideGrantActive(args.grant, now) ||\n !isSameActor(args.grant, actor)\n ) {\n return deny(\n 'no-override-grant',\n 'You hold no active approval override grant.',\n )\n }\n if (!isApprovalOverridable(job.type)) {\n return deny(\n 'not-overridable',\n 'An override cannot run this type of request. It needs its approvals.',\n )\n }\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n if (isExpired(job, now)) {\n return deny('expired', 'This request expired and can no longer be run.')\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and cannot be run with an override.`,\n )\n }\n if (args.reason.trim().length === 0) {\n return deny(\n 'no-reason',\n 'Give a reason to run this request with an override.',\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 decision that let the request run: the last\n * approval, the override (`overriddenAt`), or the time of a `scheduled`\n * request (`runAt`). A use case that needs zero approvals has none of them,\n * so its window starts when the request was opened.\n */\nexport function runWindowEnd(job: ApprovalJobDto): Date {\n const decisions = [\n ...job.acks.map((ack) => ack.date),\n job.overriddenAt,\n job.runAt,\n ]\n const start = decisions.reduce(\n (latest, date) =>\n date === undefined ? latest : Math.max(latest, new Date(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": "AAMA,SAAS,2BAA2B;AAMpC,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAoDA,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;AA4BA,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;AAOO,SAAS,gBACd,KACA,KAC0B;AAC1B,SAAO,UAAU,KAAK,GAAG,KAAK,kBAAkB,KAAK,GAAG,IACpD,YACA,IAAI;AACV;AAGO,SAAS,cAAc,KAA6B;AACzD,SAAO,KAAK,IAAI,GAAG,IAAI,eAAe,IAAI,KAAK,MAAM;AACvD;AAgBO,SAAS,mBACd,OACA,aACS;AACT,MAAI,YAAY,WAAW,GAAG;AAC5B,WAAO;AAAA,EACT;AACA,SAAO,YAAY,MAAM,CAAC,eAAe,MAAM,IAAI,UAAU,CAAC;AAChE;AAQO,SAAS,sBAAsB,KAA+B;AACnE,SAAO,IAAI;AACb;AAgBO,SAAS,QACd,KACA,OACA,SACkB;AAClB,MACE,MAAM,IAAI,QAAQ,kBAAkB,KACpC,YAAY,IAAI,WAAW,KAAK,KAChC,kBAAkB,KAAK,KAAK,GAC5B;AACA,WAAO;AAAA,EACT;AACA,SAAO,KAAK,aAAa,8BAA8B;AACzD;AAUO,SAAS,kBACd,KACgD;AAChD,MACE,IAAI,SAAS,wBAAwB,yBACrC,IAAI,SAAS,wBAAwB,wBACrC;AACA,WAAO;AAAA,EACT;AACA,QAAM,EAAE,eAAe,aAAa,IAAI,IAAI;AAC5C,MAAI,OAAO,kBAAkB,YAAY,OAAO,iBAAiB,UAAU;AACzE,WAAO;AAAA,EACT;AACA,SAAO;AAAA,IACL,QAAQ,OAAO,kBAAkB,WAAW,gBAAgB;AAAA,IAC5D,OAAO,OAAO,iBAAiB,WAAW,eAAe;AAAA,EAC3D;AACF;AAEA,SAAS,kBACP,KACA,OACS;AACT,QAAM,UAAU,kBAAkB,GAAG;AACrC,SAAO,YAAY,UAAa,YAAY,SAAS,KAAK;AAC5D;AASO,SAAS,sBACd,OACA,KACS;AACT,MAAI,CAAC,SAAS,MAAM,WAAW;AAC7B,WAAO;AAAA,EACT;AACA,SAAO,IAAI,KAAK,MAAM,SAAS,EAAE,QAAQ,IAAI,IAAI,QAAQ;AAC3D;AAaO,SAAS,UACd,OACA,SACA,UAWI,CAAC,GACa;AAKlB,
|
|
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 ApprovalOverrideGrantDto,\n ApprovalUseCaseDto,\n} from '../dtos/ApprovalJob.dto.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\nimport {\n APPROVAL_USE_CASE_TYPES,\n isApprovalOverridable,\n} from './use-cases.const.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 | 'no-override-grant'\n | 'not-overridable'\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 isOverrideGrantee(job, actor)\n ) {\n return ALLOWED\n }\n return deny('forbidden', 'You cannot see this request.')\n}\n\n/**\n * The person that a request of the two override use cases names, or\n * `undefined` for every other request.\n *\n * Four rules read it. The grantee cannot approve their own grant, also not\n * when they open it (`opensWithOwnApproval`), the grantee can read both\n * requests, and the grantee can end their own grant before its time.\n */\nexport function overrideGranteeOf(\n job: Pick<ApprovalJobDto, 'type' | 'payload'>,\n): { userId: string; email?: string } | undefined {\n if (\n job.type !== APPROVAL_USE_CASE_TYPES.grantApprovalOverride &&\n job.type !== APPROVAL_USE_CASE_TYPES.revokeApprovalOverride\n ) {\n return undefined\n }\n const { granteeUserId, granteeEmail } = job.payload\n if (typeof granteeUserId !== 'string' && typeof granteeEmail !== 'string') {\n return undefined\n }\n return {\n userId: typeof granteeUserId === 'string' ? granteeUserId : '',\n email: typeof granteeEmail === 'string' ? granteeEmail : undefined,\n }\n}\n\nfunction isOverrideGrantee(\n job: Pick<ApprovalJobDto, 'type' | 'payload'>,\n actor: ApprovalActorView,\n): boolean {\n const grantee = overrideGranteeOf(job)\n return grantee !== undefined && isSameActor(grantee, actor)\n}\n\n/**\n * Reports whether an approval override grant lets its holder act now.\n *\n * It fails closed: an absent grant, a revoked one, and a date that does not\n * parse are all inactive. control-backend reads the grant from its table at\n * every call. A token never carries it.\n */\nexport function isOverrideGrantActive(\n grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined,\n now: Date,\n): boolean {\n if (!grant || grant.revokedAt) {\n return false\n }\n return new Date(grant.expiresAt).getTime() > now.getTime()\n}\n\n/**\n * The permissions that open a request of this use case. Any one of them is\n * enough. Returns `[]` when the field is absent or empty. `canCreate` tells\n * them apart: absent opens to anybody signed in, empty to nobody.\n */\nexport function createPermissionsOf(\n useCase: Pick<ApprovalUseCaseDto, 'createPermission'>,\n): string[] {\n const { createPermission } = useCase\n if (createPermission === undefined) {\n return []\n }\n return typeof createPermission === 'string'\n ? [createPermission]\n : [...createPermission]\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 const createPermissions = createPermissionsOf(useCase)\n // An empty list is a mistake of the use case. It must close the request,\n // and not open it to everyone, so it is checked on the DTO and not on the\n // list that `createPermissionsOf` returns.\n if (\n useCase.createPermission !== undefined &&\n !createPermissions.some((permission) => actor.has(permission))\n ) {\n return deny(\n 'forbidden',\n createPermissions.length === 0\n ? 'Nobody can open this request, because its use case lists no permission.'\n : `Opening this request needs ${createPermissions.join(' or ')}, and ${\n createPermissions.length > 1\n ? 'you hold none of them'\n : 'you do not hold it'\n }.`,\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 && createPermissions.length > 0\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 (\n needsReason(actor, useCase, {\n requiredAcks: request.requiredAcks,\n requiredPermissions: toRun,\n }) &&\n (request.reason ?? '').trim().length === 0\n ) {\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 * Reports whether the opener approves a new request when it is created.\n *\n * The opener approves when the request needs an approval and the opener holds\n * every run permission of it. `APPROVAL_RULES` counts this approval. A request\n * that needs no approval gets none, so its `approvedBy` stays empty.\n *\n * When `request` is given, the grantee of an approval override grant does not\n * approve the grant that they open for themselves, as `canAcknowledge`\n * refuses their approval later. Every approval of such a request then comes\n * from another person.\n */\nexport function opensWithOwnApproval(\n actor: ApprovalActorView,\n requiredAcks: number,\n requiredPermissions: string[],\n request?: Pick<ApprovalJobDto, 'type' | 'payload'>,\n): boolean {\n if (request !== undefined && isOverrideGrantee(request, actor)) {\n return false\n }\n return requiredAcks > 0 && hasEveryPermission(actor, requiredPermissions)\n}\n\n/**\n * Reports whether a new request needs a reason.\n *\n * A request needs no reason when its opener holds every run permission and\n * needs no second person. Nobody else reads the reason before the action\n * runs, and the audit record names the opener.\n */\nexport function needsReason(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: { requiredAcks?: number; requiredPermissions?: string[] } = {},\n): boolean {\n if (!useCase.reasonRequired) {\n return false\n }\n return !(\n resolveRequiredAcks(useCase, request.requiredAcks) <= 1 &&\n hasEveryPermission(\n actor,\n request.requiredPermissions ?? [useCase.requiredPermission],\n )\n )\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 (isOverrideGrantee(job, actor)) {\n return deny(\n 'self-approval',\n 'This request gives you an override grant, 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`, when the row got all the approvals that the request asks for.\n * One of them can be the approval of the opener at create (see\n * `opensWithOwnApproval`), and `canAcknowledge` keeps each person to one\n * approval. The button only carries out what these people already agreed to.\n * 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 // The grantee may end their own grant early, with no permission. Ending a\n // grant takes power away, so it needs no second person.\n // A `failed` revoke counts too: the reconciler does not run a failed row\n // again, so the grantee must be able to retry it.\n const endsOwnGrant =\n (job.status === 'scheduled' || job.status === 'failed') &&\n job.type === APPROVAL_USE_CASE_TYPES.revokeApprovalOverride &&\n isOverrideGrantee(job, actor)\n if (!endsOwnGrant && !hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n // The grantee cannot run the request that gives them their grant, as\n // `canAcknowledge` refuses their approval. A retry of a `failed` grant would\n // let them pick the time when the grant starts.\n if (\n job.type === APPROVAL_USE_CASE_TYPES.grantApprovalOverride &&\n isOverrideGrantee(job, actor)\n ) {\n return deny(\n 'self-approval',\n 'This request gives you an override grant, so you cannot run it.',\n )\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 // A `scheduled` request runs by itself at `runAt`. To run it before that\n // time needs the same permissions as any other run.\n if (\n job.status !== 'approved' &&\n job.status !== 'failed' &&\n job.status !== 'scheduled'\n ) {\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 * Why a request that an override approved can no longer run: the grant that\n * the override used ended. The revoke of a grant writes it as `closedReason`\n * on each such request, and `canRunOverridden` answers it.\n */\nexport const OVERRIDE_GRANT_ENDED_MESSAGE =\n 'The approval override grant that ran this request ended. Open a new request.'\n\n/**\n * Reports whether a request that an override ran can still run.\n *\n * An override of a request in the explicit mode moves it to `approved`, and a\n * person runs it later. A failed run can also run again. The grant is the only\n * thing that stood in for the approvals, so each run needs it to be active\n * still: a revoke or the end of the grant stops the request too.\n *\n * `grant` is the grant that the override used (`job.overrideGrantId`), which\n * the caller reads from the grant table at the run. A later grant of the same\n * person does not count: a revoke ends the requests that its grant ran, and a\n * new grant must not bring them back. A request with no override is not\n * affected. A type that joined `APPROVAL_OVERRIDE_DENYLIST` after the\n * override needs its approvals, as `canOverride` says.\n */\nexport function canRunOverridden(\n job: Pick<ApprovalJobDto, 'type' | 'overriddenBy'>,\n grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined,\n now: Date,\n): ApprovalDecision {\n if (!job.overriddenBy) {\n return ALLOWED\n }\n if (!isApprovalOverridable(job.type)) {\n return deny(\n 'not-overridable',\n 'An override cannot run this type of request. It needs its approvals.',\n )\n }\n if (isOverrideGrantActive(grant, now)) {\n return ALLOWED\n }\n return deny('no-override-grant', OVERRIDE_GRANT_ENDED_MESSAGE)\n}\n\n/**\n * Runs a `pending` request with an approval override grant, in place of the\n * approvals that it still needs.\n *\n * `grant` is the active grant of the actor. The caller reads it from the\n * grant table at every call, and never from a token: a token keeps its\n * permissions until it expires, and a revoked grant must stop at once.\n *\n * The creator may override their own request, because that is what a grant\n * is for. The actor still needs the permissions of the request: a grant\n * takes the place of the second person, and not of the permission. A use case\n * in `APPROVAL_OVERRIDE_DENYLIST` always needs its approvals.\n */\nexport function canOverride(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n args: {\n grant?: Pick<\n ApprovalOverrideGrantDto,\n 'userId' | 'email' | 'expiresAt' | 'revokedAt'\n >\n reason: string\n },\n): ApprovalDecision {\n if (\n !args.grant ||\n !isOverrideGrantActive(args.grant, now) ||\n !isSameActor(args.grant, actor)\n ) {\n return deny(\n 'no-override-grant',\n 'You hold no active approval override grant.',\n )\n }\n if (!isApprovalOverridable(job.type)) {\n return deny(\n 'not-overridable',\n 'An override cannot run this type of request. It needs its approvals.',\n )\n }\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n if (isExpired(job, now)) {\n return deny('expired', 'This request expired and can no longer be run.')\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and cannot be run with an override.`,\n )\n }\n if (args.reason.trim().length === 0) {\n return deny(\n 'no-reason',\n 'Give a reason to run this request with an override.',\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 decision that let the request run: the last\n * approval, the override (`overriddenAt`), or the time of a `scheduled`\n * request (`runAt`). A use case that needs zero approvals has none of them,\n * so its window starts when the request was opened.\n */\nexport function runWindowEnd(job: ApprovalJobDto): Date {\n const decisions = [\n ...job.acks.map((ack) => ack.date),\n job.overriddenAt,\n job.runAt,\n ]\n const start = decisions.reduce(\n (latest, date) =>\n date === undefined ? latest : Math.max(latest, new Date(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
|
}
|
|
@@ -12,13 +12,19 @@ const CONFIG = {
|
|
|
12
12
|
Marketing: ["accounts:read", "accounts:trialExtender"],
|
|
13
13
|
"205882307": ["accounts:read", "accounts:trialExtender"],
|
|
14
14
|
// revops
|
|
15
|
-
Products
|
|
15
|
+
// Engineering and Products open the requests that change a feature flag
|
|
16
|
+
// on int and prod. Only `aafw` approves one.
|
|
17
|
+
Products: [
|
|
18
|
+
"console:admin:readonly",
|
|
19
|
+
"accounts:read",
|
|
20
|
+
"accounts:featureFlagRequester"
|
|
21
|
+
],
|
|
16
22
|
Executive: ["accounts:read"],
|
|
17
23
|
"259633605": ["accounts:read", "accounts:trialExtender"]
|
|
18
24
|
// Sales
|
|
19
25
|
},
|
|
20
26
|
divisionRules: {
|
|
21
|
-
Engineering: ["console:admin:readonly"],
|
|
27
|
+
Engineering: ["console:admin:readonly", "accounts:featureFlagRequester"],
|
|
22
28
|
"259633608": [
|
|
23
29
|
"accounts:connections:create",
|
|
24
30
|
"accounts:connections:update",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/config/camundi-permission-rules.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 type { CamundiPermissionRules } from './camundi-permission-rules.types.js'\n\nconst CONFIG: CamundiPermissionRules = {\n departmentRules: {\n // CS opens and approves the requests that remove an org member, an\n // owner included, for example when a customer hands an organization to a\n // new owner. Each request needs a second Camundi.\n '205882305': [\n 'accounts:read',\n 'accounts:trialExtender',\n 'accounts:orgMemberRemover',\n ], // CS\n Marketing: ['accounts:read', 'accounts:trialExtender'],\n '205882307': ['accounts:read', 'accounts:trialExtender'], // revops\n Products: ['console:admin:readonly'
|
|
5
|
-
"mappings": "AAQA,MAAM,SAAiC;AAAA,EACrC,iBAAiB;AAAA;AAAA;AAAA;AAAA,IAIf,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA,IACA,WAAW,CAAC,iBAAiB,wBAAwB;AAAA,IACrD,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,
|
|
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 type { CamundiPermissionRules } from './camundi-permission-rules.types.js'\n\nconst CONFIG: CamundiPermissionRules = {\n departmentRules: {\n // CS opens and approves the requests that remove an org member, an\n // owner included, for example when a customer hands an organization to a\n // new owner. Each request needs a second Camundi.\n '205882305': [\n 'accounts:read',\n 'accounts:trialExtender',\n 'accounts:orgMemberRemover',\n ], // CS\n Marketing: ['accounts:read', 'accounts:trialExtender'],\n '205882307': ['accounts:read', 'accounts:trialExtender'], // revops\n // Engineering and Products open the requests that change a feature flag\n // on int and prod. Only `aafw` approves one.\n Products: [\n 'console:admin:readonly',\n 'accounts:read',\n 'accounts:featureFlagRequester',\n ],\n Executive: ['accounts:read'],\n '259633605': ['accounts:read', 'accounts:trialExtender'], // Sales\n },\n divisionRules: {\n Engineering: ['console:admin:readonly', 'accounts:featureFlagRequester'],\n '259633608': [\n 'accounts:connections:create',\n 'accounts:connections:update',\n 'support:agent',\n ], // Support\n '259633611': [\n 'accounts:trialExtender',\n 'accounts:limitExtender',\n 'support:agent',\n ], // Pre-Sales as per https://confluence.camunda.com/spaces/HAN/pages/168660217/Adjust+C8+SaaS+permissions+and+sales+plan#AdjustC8SaaSpermissionsandsalesplan-DetailsperRole\n // Professional Services holds 36 people, the consultants. Six of them\n // sit in the Education subdivision. They train customers and do not\n // work on customer clusters.\n //\n // The augmenter can only ADD a role, so no rule here can EXCLUDE\n // Education from this grant. The other 30 consultants carry no\n // subdivision at all, so a subdivision rule cannot name them either.\n //\n // This grant covers the whole division on purpose. A rule that keys on\n // the ABSENCE of a subdivision id is more fragile than one that keys on\n // a present id, and two comments in this file already record a\n // subdivision id that a HiBob restructure broke without warning.\n //\n // If the 30 consultants without a subdivision get one of their own one\n // day, move this grant down to that subdivision and remove this rule.\n // Education would then stop inheriting the grant, because the division\n // no longer carries it and the subdivision rule never named Education.\n '266189454': ['console:clusterMover'], // Professional Services\n // Enterprise Success holds the CSMs and the TAMs. A customer who has\n // privacy concerns asks them to switch optional telemetry off, thus they\n // carry that capability. HiBob marks no TAM, thus the division is the\n // smallest group that holds all of them. The CSMs and TAMs also move a\n // cluster to another org for a customer, thus they carry\n // `console:clusterMover` too.\n '266229124': ['console:optionalTelemetryManager', 'console:clusterMover'], // Enterprise Success\n },\n subdivisionRules: {\n // The console team is the \"Control plane\" subdivision. The id was\n // `256909487` until a HiBob restructure, and the augmenter matches a\n // subdivision exactly and stays quiet on a miss, thus the rule granted\n // nothing to anybody and nobody saw it.\n '267516827': ['accounts:admin', 'console:admin', 'consoleTeam'], // Control plane\n 'Engineering Management': [\n 'accounts:addSafeRoles',\n 'accounts:orgCreator',\n 'console:C8ReleaseManager',\n ], // Engineering Management\n // Same story: the id `258049291` is gone. The subdivision carries the name\n // `Controller` now, and a name is a valid key \u2014 `Engineering Management`\n // above is one too.\n Controller: ['console:clusterPlanManager', 'console:operationsEngineer'],\n },\n}\nexport default CONFIG\n"],
|
|
5
|
+
"mappings": "AAQA,MAAM,SAAiC;AAAA,EACrC,iBAAiB;AAAA;AAAA;AAAA;AAAA,IAIf,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA,IACA,WAAW,CAAC,iBAAiB,wBAAwB;AAAA,IACrD,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA;AAAA;AAAA,IAGvD,UAAU;AAAA,MACR;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACA,WAAW,CAAC,eAAe;AAAA,IAC3B,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,EACzD;AAAA,EACA,eAAe;AAAA,IACb,aAAa,CAAC,0BAA0B,+BAA+B;AAAA,IACvE,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA,IACA,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAkBA,aAAa,CAAC,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOpC,aAAa,CAAC,oCAAoC,sBAAsB;AAAA;AAAA,EAC1E;AAAA,EACA,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA,IAKhB,aAAa,CAAC,kBAAkB,iBAAiB,aAAa;AAAA;AAAA,IAC9D,0BAA0B;AAAA,MACxB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA,IAIA,YAAY,CAAC,8BAA8B,4BAA4B;AAAA,EACzE;AACF;AACA,IAAO,mCAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -560,12 +560,14 @@ export interface ApprovalUseCaseDto {
|
|
|
560
560
|
requiredPermission: string;
|
|
561
561
|
/**
|
|
562
562
|
* The permission to open a request. Absent means any signed-in Camundi.
|
|
563
|
+
* A list means that any one of its permissions is enough. An empty list
|
|
564
|
+
* opens the request to nobody, so give a list at least one entry.
|
|
563
565
|
*
|
|
564
566
|
* Set it when the pool that can ASK must be as small as the pool that can
|
|
565
567
|
* approve. Leave it absent when the approvals alone are the guard, and the
|
|
566
|
-
* proposal is not.
|
|
568
|
+
* proposal is not. Read it with `createPermissionsOf`.
|
|
567
569
|
*/
|
|
568
|
-
createPermission?: string;
|
|
570
|
+
createPermission?: string | string[];
|
|
569
571
|
/** True when a request cannot be opened without a reason. */
|
|
570
572
|
reasonRequired: boolean;
|
|
571
573
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/dtos/ApprovalJob.dto.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 wire types of the generic approval mechanism.\n *\n * This file holds no logic and no dependency, because three consumers read it:\n * control-backend, control-frontend and cloudbot. The rules that read these\n * types are in `../approvals/policy.js`, and they are pure for the same reason.\n */\n\n/**\n * Every state that an approval request can hold.\n *\n * A `const` tuple, not an enum, so it is both the union type and the list that\n * validates a status filter at run time.\n *\n * The path of a request: `pending` until it reaches the threshold. Then\n * `executing` (automatic mode) or `approved` (explicit mode, which waits for a\n * person to press the button). `executed` or `failed` after the action ran. A\n * request that never reaches the threshold ends `rejected`, `withdrawn` or\n * `expired`.\n *\n * `scheduled` is a request that the service opened by itself, and that runs\n * at `runAt` with no approval. It does not expire, and nobody can withdraw or\n * reject it. A holder of its permissions can run it before `runAt`.\n */\nexport const APPROVAL_JOB_STATUSES = [\n 'pending',\n 'scheduled',\n 'approved',\n 'executing',\n 'executed',\n 'failed',\n 'rejected',\n 'withdrawn',\n 'expired',\n] as const\n\nexport type ApprovalJobStatus = (typeof APPROVAL_JOB_STATUSES)[number]\n\n/**\n * The states in which the action of a request is still COMING.\n *\n * `pending` waits for approvals, `scheduled` waits for its time, `approved`\n * waits for a person to press the button, and `executing` is running now. Each of them can still change the\n * world, so two requests for one target must not be in these states together.\n * `dedupeKey` reads this set for that reason.\n *\n * `failed` is NOT here, although a person can run a failed request again. A\n * failed request runs only when a person presses the button, and that person\n * sees the row. Blocking a new request on a failed one would also trap the\n * key: `canWithdraw` accepts `pending` alone, so nobody could close it.\n *\n * `executed`, `rejected`, `withdrawn` and `expired` are the end of a request.\n */\nexport const APPROVAL_LIVE_STATUSES = [\n 'pending',\n 'scheduled',\n 'approved',\n 'executing',\n] as const satisfies readonly ApprovalJobStatus[]\n\n/**\n * How many requests one page of the list holds when the caller asks for no\n * number.\n *\n * The list is a work queue that a Camundi reads, so the page is large: a\n * person wants the whole queue and not ten rows at a time. What the number is\n * for is the ceiling below.\n */\nexport const APPROVAL_PAGE_SIZE_DEFAULT = 500\n\n/**\n * The largest page that the service builds, and the largest that a caller may\n * ask for. The API answers 400 above it.\n *\n * The list had no limit at all. One authenticated caller could then make the\n * service read the whole table into memory, serialize it, and do it again, so\n * the cost of a request grew with the age of the service and with nothing that\n * the caller had to pay for. A ceiling turns that into a fixed cost.\n *\n * It is HERE and not only in the service, because the view must know it: a\n * page that asked for more would get a 400 instead of rows.\n */\nexport const APPROVAL_PAGE_SIZE_MAX = 2000\n\n/**\n * The columns that the queue can be ordered by.\n *\n * A NAME and not an SQL expression: the repository maps each one, so a caller\n * can never name a column that this list does not hold. `status` orders by the\n * EFFECTIVE status, because a `pending` row whose time ran out reads as\n * `expired` everywhere else.\n */\nexport const APPROVAL_SORT_FIELDS = [\n 'createdAt',\n 'expiresAt',\n 'status',\n 'type',\n 'createdBy',\n] as const\n\nexport type ApprovalSortField = (typeof APPROVAL_SORT_FIELDS)[number]\n\nexport type ApprovalSortOrder = 'asc' | 'desc'\n\n/** The default order of the queue: the newest request first. */\nexport const APPROVAL_SORT_DEFAULT: {\n sort: ApprovalSortField\n order: ApprovalSortOrder\n} = { sort: 'createdAt', order: 'desc' }\n\n/**\n * The longest reason that a request, a rejection or a withdrawal accepts.\n *\n * ONE number, because three routes of control-backend hold it and three fields\n * of control-frontend must stop there. A field that let a person write more\n * than the schema accepts turns their sentence into a 400 after they wrote it.\n */\nexport const APPROVAL_REASON_MAX_LENGTH = 2000\n\n/**\n * The largest number of approvals that a caller can ask for.\n *\n * A request that asks for 400 approvals can never reach them, so it is a\n * request that only fills the table and expires. The ceiling turns that into a\n * 400 at the moment a person types it. It is HERE because the form of the\n * bulk operation must stop at the same number as the route.\n */\nexport const APPROVAL_ACKS_MAX = 10\n\n/**\n * The longest search text that the list accepts.\n *\n * A search runs `ILIKE` over the payload of every visible row, so the cost of\n * one call follows the length of the text. Nothing a person types to find a\n * request is near this.\n */\nexport const APPROVAL_SEARCH_MAX_LENGTH = 200\n\n/**\n * APPROVAL_TYPE_FILTER_MAX is the most use case types that one `type` filter\n * names. The registry holds fewer, so the ceiling only stops a list with no\n * end. control-backend answers 400 above it.\n */\nexport const APPROVAL_TYPE_FILTER_MAX = 100\n\n/** APPROVAL_TYPE_MAX_LENGTH is the longest type that a `type` filter names. */\nexport const APPROVAL_TYPE_MAX_LENGTH = 200\n\n/**\n * The payload field that names the organization a request acts on.\n *\n * It is a CONVENTION, and this constant is what makes the convention readable\n * from both sides. A use case whose action hits one organization puts the id of\n * that organization in this field, and `GET /api/approvals?orgId=\u2026` then finds\n * the request with no knowledge of the use case. The filter reads this one\n * field of the payload, so it matches the whole id and nothing else. The free\n * text search reads the payload as TEXT, which also matches an id that only\n * appears inside another field, and it cannot answer \"the requests of this\n * organization\".\n *\n * The organization page of control-frontend does NOT read this field. It asks\n * the entity-link filter (`entityType=org`), which reads a row that\n * `relatedEntities` of a use case writes \u2014 see `ApprovalEntityRef`. The summary\n * is separate again: the `org` field of a `summarize` result is what a READER\n * follows.\n */\nexport const APPROVAL_PAYLOAD_ORG_FIELD = 'orgId'\n\n/**\n * How many requests each status and each use case type holds, for the filters\n * of the queue.\n *\n * Every status number answers the SAME question as the list beside it: the\n * counts are built with the visibility rule, the action filter and the search\n * text of the caller, and with the lazy expiry. A count that ignored one of\n * those would send a person to a filter that then shows nothing. `byType` is\n * the exception: see its own note.\n */\nexport interface ApprovalStatusCountsDto {\n /** Every request that matches, whatever its status. */\n all: number\n /** One number for each status. A status with no request is 0, never absent. */\n byStatus: Record<ApprovalJobStatus, number>\n /**\n * One number for each use case type, over every status.\n *\n * It ignores the type filter, so a type facet keeps its number while it is\n * on or off. Every other filter applies. A type with no request has no key,\n * so read a missing key as 0, and read only an own key: a type id such as\n * `__proto__` must not reach the prototype.\n *\n * Optional, because an older control-backend does not send it. Read an\n * absent field as no counts.\n */\n byType?: Record<string, number>\n}\n\n/**\n * What happens when a request reaches its threshold.\n *\n * `auto` runs the action immediately. `explicit` holds the request at\n * `approved` and waits for a person to run it.\n */\nexport type ApprovalExecutionMode = 'auto' | 'explicit'\n\n/**\n * WHERE the action runs. It is a separate question from who starts it.\n *\n * - `inline` runs the action inside the call that started it, and the answer\n * already carries the outcome. Right for one call to another service.\n * - `background` answers as soon as the request is claimed, and the action\n * runs on. It reports into `ApprovalJobDto.progress`, and the row is the\n * only place that holds the progress, so a reload and a second person read\n * the same numbers. A pod that stops loses the run, and the reconciler of\n * control-backend recovers the row.\n *\n * An action over thousands of targets MUST be `background`. `inline` would\n * hold one HTTP request for the whole run, and the ingress, the load balancer\n * and the browser each have their own deadline before it finishes.\n *\n * An `inline` action must not read `context.signal`. The runner trusts the\n * result of an `inline` action that returns after a drain, and it would count\n * a partial run as `executed`. An action that stops between targets is\n * `background`.\n */\nexport type ApprovalActionMode = 'inline' | 'background'\n\n/**\n * A person, as an approval request records them.\n *\n * Both keys are kept. The machine then never has to choose one identity key,\n * which is what split the earlier acknowledgement flows: some held an email and\n * some held a user id, and the two could not be compared.\n */\nexport interface ApprovalActor {\n userId: string\n email: string\n}\n\nexport interface ApprovalAck extends ApprovalActor {\n /** An ISO timestamp. It is in a json column, so it is a string, not a Date. */\n date: string\n}\n\n/**\n * Who asked for an action, and who agreed to it.\n *\n * Every route of `api/admin/approvals` takes this envelope, and control-backend\n * builds it from the request that it runs. The envelope exists because the\n * token cannot carry it: the call is machine to machine, so `sub` holds the id\n * of the Auth0 application and not a person. A service that reads the token\n * alone therefore writes \"control-backend\" into its audit record, which hides\n * the two Camundis that the whole mechanism exists to record.\n *\n * `approvedBy` is a list, because a use case can need more than one approval,\n * and the record must name all of them. It can also be EMPTY: how many\n * approvals an action needs is a number of the use case, and `move-cluster-org`\n * holds zero on purpose. The person in `requestedBy` is never in the list: the\n * four-eyes rule is that the person who asks is not the person who agrees, and\n * control-backend holds that rule.\n *\n * Each person carries both keys of `ApprovalActor`. The email is what a route\n * uses to ask Auth0 about the person, and the user id is what an audit record\n * holds, because every other record of the fleet holds an Auth0 `sub`.\n *\n * CAUTION: The envelope is a CLAIM of the caller, and not proof. A route that\n * acts on it must ask Auth0 again whether `requestedBy` may do the action.\n * That means the permission of the action. For a use case with\n * `openToEveryone`, it means that the person is a Camundi, because no\n * permission runs that action. `api/admin/approvals` in accounts-backend does\n * that. See the note on `M2M_CONTROL_APPROVALS`.\n */\nexport interface ApprovalM2mActor {\n /** The id of the approval request, to join the records of both services. */\n requestId: string\n /** The Camundi who opened the request. */\n requestedBy: ApprovalActor\n /** The Camundis who approved it. Empty when the use case needs none. */\n approvedBy: ApprovalActor[]\n /**\n * The Camundi who ran the request with an approval override grant, in\n * place of the approvals that it still needed. Absent for every other run.\n *\n * control-backend checked the grant, the permissions of this person and\n * `APPROVAL_OVERRIDE_DENYLIST` before the run. A receiver that counts the\n * approvals of an overridable type accepts this person in place of the\n * approvals that are missing, and refuses this field for every other type.\n * This person can be the person in `requestedBy`, because that is what a\n * grant is for. A receiver that refuses the target of an action as an\n * approver refuses this person as well, also when they asked.\n */\n overriddenBy?: ApprovalActor\n}\n\n/**\n * What one value of a summary field is.\n *\n * `text` is what a person reads. `id` is what a link points at, and it is\n * absent when the kind names no entity. The two are separate because the text\n * is a COPY that the request keeps: an organization that gets a new name after\n * the request was opened must still read as the organization that the person\n * chose.\n */\nexport interface ApprovalSummaryValue {\n text: string\n id?: string\n}\n\n/**\n * How a reader must render one field of a summary.\n *\n * `org`, `cluster` and `user` name an entity, so a reader can link to it and\n * can resolve a fresh name. The others are plain: `date` is an ISO timestamp,\n * and `code` is a value that must keep its characters, such as a json fragment.\n */\nexport const APPROVAL_SUMMARY_FIELD_KINDS = [\n 'text',\n 'number',\n 'boolean',\n 'date',\n 'org',\n 'cluster',\n 'user',\n 'code',\n] as const\n\nexport type ApprovalSummaryFieldKind =\n (typeof APPROVAL_SUMMARY_FIELD_KINDS)[number]\n\n/**\n * One labelled field of the payload of a request.\n *\n * This is what makes ONE view able to show EVERY use case. The payload of a\n * request is an object of anything, so a generic view can only print it as\n * json. The use case turns it into labelled fields here, and the view renders\n * a field by its kind: it links an `org`, it formats a `date`, and it lists\n * 300 clusters as 300 links.\n *\n * `values` is always an array. A field that holds one value holds an array of\n * one, so a reader needs no branch for \"one or many\".\n */\nexport interface ApprovalSummaryField {\n label: string\n kind: ApprovalSummaryFieldKind\n values: ApprovalSummaryValue[]\n /**\n * The values that this request replaces, for a field that changes. A view\n * shows them as removed beside `values` as added. A field with no\n * `previous` is not a change.\n */\n previous?: ApprovalSummaryValue[]\n /** The count of `previous`, when the list cut it. Like `total`, for `previous`. */\n previousTotal?: number\n /**\n * How many values the field holds, when `values` holds only the first of\n * them. The list route cuts every field to `APPROVAL_LIST_SUMMARY_VALUES_MAX`\n * values. The detail route sends every value and no `total`.\n */\n total?: number\n}\n\n/**\n * How many values of one summary field a row of the list carries.\n *\n * A bulk operation names up to 3000 clusters in one field. A page of 100 such\n * rows would carry 300,000 values that no row shows. The view shows at most\n * 12 values of a field, so the list stops there, and `total` holds the count.\n */\nexport const APPROVAL_LIST_SUMMARY_VALUES_MAX = 12\n\n/**\n * The kinds of entity that an approval request can concern.\n *\n * These are the entities that a request is findable by: the org and cluster\n * detail pages ask \"which requests touch me\", and the answer reads a row for\n * each entity. It is a subset of `APPROVAL_SUMMARY_FIELD_KINDS` \u2014 the kinds that\n * name an entity \u2014 because only those carry an id that a page can match.\n */\nexport const APPROVAL_ENTITY_TYPES = ['cluster', 'org', 'user'] as const\n\nexport type ApprovalEntityType = (typeof APPROVAL_ENTITY_TYPES)[number]\n\n/**\n * One entity that an approval request concerns.\n *\n * A use case reports these from its payload, and the service writes one row for\n * each into `approval_job_entity`. That row is what makes a request findable by\n * an entity it touches, without a text search over the payload: a cluster-move\n * request names its cluster, its target organization AND its source\n * organization, so it shows on all three detail pages. The `summary` still\n * carries the words a reader sees; this carries only the ids a page matches.\n */\nexport interface ApprovalEntityRef {\n type: ApprovalEntityType\n id: string\n}\n\n/**\n * How long a run may go quiet before a reader calls it stalled.\n *\n * The pod that runs an action writes its progress and renews its lease on one\n * timer, so a healthy run touches the row well inside this window. A row that\n * is `executing` and older than this held a pod that stopped, and the\n * reconciler of control-backend is on its way to it.\n *\n * It lives here, and not in the config of the service, because a READER needs\n * it: the browser must decide what to show without a second call, and a number\n * that only the backend knew would make the two disagree. `ACTION_LEASE_MS` in\n * `config/index.ts` is held below this on purpose.\n */\nexport const APPROVAL_RUN_STALE_MS = 120_000\n\n/**\n * What an action reports while it runs.\n *\n * This is what makes a long action watchable. Every field is in the DATABASE\n * and not in the answer to the call that started the run, so three readers see\n * the same thing: the person who pressed the button, the same person after a\n * reload, and a second person who opens the request.\n *\n * `total` can be 0 while an action counts its targets, and it stays 0 for an\n * action that cannot count them. A reader must then show that it runs, and no\n * percentage.\n */\nexport interface ApprovalProgress {\n /** Targets in total. 0 means the action did not say. */\n total: number\n /** Targets finished, whatever the outcome of each one. */\n done: number\n /** Targets that finished with an error. Part of `done`. */\n failed: number\n /** One short line for a person, such as the step that runs now. */\n note?: string\n /** An ISO timestamp: when the run started. */\n startedAt: string\n /** An ISO timestamp: when the run last wrote anything. */\n updatedAt: string\n}\n\n/** The share of the work that is finished, or `undefined` with no total. */\nexport function progressFraction(\n progress: ApprovalProgress | undefined,\n): number | undefined {\n if (!progress || progress.total <= 0) {\n return undefined\n }\n return Math.min(1, progress.done / progress.total)\n}\n\n/**\n * Reports whether a running request went quiet.\n *\n * True means that the pod which held the run stopped, or lost the row. It does\n * NOT mean that the action failed: the reconciler either runs it again, or it\n * writes `failed` so that a person can. See `APPROVAL_RUN_STALE_MS`.\n */\nexport function isRunStalled(job: ApprovalJobDto, now: Date): boolean {\n if (job.status !== 'executing') {\n return false\n }\n const lastSign = job.progress?.updatedAt ?? job.executingSince\n if (!lastSign) {\n return false\n }\n return now.getTime() - new Date(lastSign).getTime() > APPROVAL_RUN_STALE_MS\n}\n\n/** One approval request, as the API returns it. */\nexport interface ApprovalJobDto {\n id: string\n type: string\n dedupeKey?: string\n payload: Record<string, unknown>\n /**\n * The payload as labelled fields. See `ApprovalSummaryField`.\n *\n * The use case builds it when the request is opened AND every time the\n * request is read, so a field that a later version of the use case adds\n * appears on a request that is already open.\n */\n summary: ApprovalSummaryField[]\n /** Why the person opened the request. Some use cases demand one. */\n reason?: string\n createdBy: ApprovalActor\n acks: ApprovalAck[]\n /**\n * How many approvals THIS request needs.\n *\n * It is on the request and not only on the use case, because the person who\n * opens one can ask for more than the floor of the use case. A bulk\n * operation over 3000 clusters is the same action as one over 3, and it can\n * still deserve a third pair of eyes. `ApprovalUseCaseDto.minAcks` is the\n * floor that a caller cannot go below.\n */\n requiredAcks: number\n /**\n * What a person must hold to approve, reject or run THIS request.\n *\n * It is on the request and not only on the use case, because a payload can\n * raise the guard: a generation update with `yoloForceUpdate` needs the yolo\n * permission on top of the base one, and the same use case without the flag\n * does not. The set is computed ONE time, when the request is opened, so it\n * is also the record of what the request was guarded by.\n *\n * A person needs EVERY permission in the set, not one of them.\n */\n requiredPermissions: string[]\n executionMode: ApprovalExecutionMode\n actionMode: ApprovalActionMode\n status: ApprovalJobStatus\n closedBy?: ApprovalActor\n closedReason?: string\n /** An ISO timestamp. */\n expiresAt: string\n /**\n * An ISO timestamp: when the current run took the request.\n *\n * It is set every time a run starts, so a run that the reconciler starts\n * again moves it. `attempts` says how many runs there were.\n */\n executingSince?: string\n /** How many times a run started. A recovery raises it. */\n attempts: number\n /**\n * What the action reports while it runs. See `ApprovalProgress`.\n *\n * It stays on the row after the run, so the record of a finished run says\n * how many targets it touched and how many refused.\n */\n progress?: ApprovalProgress\n executedAt?: string\n executionError?: string\n /**\n * What the action reported when it ran.\n *\n * The shape belongs to the use case. An action over many targets puts the\n * result of each target here, which is the difference between \"it failed\"\n * and \"it did 2998 of 3000, and these two refused\".\n */\n executionResult?: Record<string, unknown>\n /** An ISO timestamp: when a `scheduled` request runs by itself. */\n runAt?: string\n /**\n * The person who ran this request with an approval override grant, in\n * place of the approvals that it still needed. See `canOverride`.\n */\n overriddenBy?: ApprovalActor\n /**\n * An ISO timestamp: when that person used the override. The run window of\n * an overridden request starts here. See `runWindowEnd`.\n */\n overriddenAt?: string\n /** Why that person used the override. It is necessary. */\n overrideReason?: string\n /**\n * The approval override grant that the override used. A later run needs\n * this grant to be active still. See `canRunOverridden`.\n */\n overrideGrantId?: string\n createdAt: string\n updatedAt: string\n}\n\n/**\n * One approval override grant, as the API returns it.\n *\n * A grant lets one Camundi run a `pending` request of an overridable use case\n * with no approval, until `expiresAt`. A grant is active while `revokedAt` is\n * absent and `expiresAt` is in the future. See `isOverrideGrantActive`.\n */\nexport interface ApprovalOverrideGrantDto {\n id: string\n /** The Auth0 `sub` of the person who holds the grant. */\n userId: string\n email: string\n /** The `grant-approval-override` request that made this grant. */\n grantedByJobId: string\n /** The `revoke-approval-override` request that ends this grant. */\n revokeJobId?: string\n reason: string\n /** An ISO timestamp. */\n createdAt: string\n /** An ISO timestamp. The grant ends here, also when no revoke ran. */\n expiresAt: string\n /** An ISO timestamp: when the revoke request ran. */\n revokedAt?: string\n revokedByJobId?: string\n}\n\n/**\n * What a use case is, as the API reports it.\n *\n * The frontend needs `requiredPermission` to run the same rule as the backend.\n * PR #9301 kept this registry a second time, by hand, in the frontend. A\n * registry that the server owns means a new use case needs no frontend deploy.\n */\nexport interface ApprovalUseCaseDto {\n type: string\n /** A short name for a person to read. */\n label: string\n /** One sentence: what the action does, and what it cannot undo. */\n description: string\n /** The approvals a new request asks for when the caller names no number. */\n defaultAcks: number\n /**\n * The floor. A caller can ask for more approvals, never for fewer.\n *\n * It is a separate number from `defaultAcks` because the two answer\n * different questions: the default is what the form offers, and the floor is\n * what the service refuses to go below.\n */\n minAcks: number\n ttlDays: number\n executionMode: ApprovalExecutionMode\n /** See `ApprovalActionMode`. An action over many targets is `background`. */\n actionMode: ApprovalActionMode\n /**\n * True when a run that was interrupted can start again by itself.\n *\n * It is a promise about the ACTION, and the reconciler reads it. Set it only\n * when the action can run a second time over the same payload and do no harm\n * twice: it must read what the first run already finished, out of\n * `ApprovalJobDto.executionResult`, and skip it.\n *\n * False, the default, is the safe answer. An interrupted run then lands in\n * `failed`, which a person can run again from the detail page, and a person\n * decides whether running it twice is safe.\n */\n resumable: boolean\n /**\n * The permission that every request of this use case needs to approve,\n * reject or run. A payload can add more. See\n * `ApprovalJobDto.requiredPermissions`, which is the effective set.\n *\n * It is also the permission to SEE a request of this use case, because a\n * person who cannot act on one has no reason to read it. The person who\n * opened a request always sees that request.\n *\n * The one exception to \"or run\" is a use case with `openToEveryone`. It has\n * no approval, so this permission only decides who sees the requests of\n * other people. Any signed-in person can open and run such a request.\n */\n requiredPermission: string\n /**\n * The permission to open a request. Absent means any signed-in Camundi.\n *\n * Set it when the pool that can ASK must be as small as the pool that can\n * approve. Leave it absent when the approvals alone are the guard, and the\n * proposal is not.\n */\n createPermission?: string\n /** True when a request cannot be opened without a reason. */\n reasonRequired: boolean\n /**\n * True when the ACTION needs the email address of the person who asked.\n *\n * A token can carry no address, and the row then stores\n * `UNKNOWN_ACTOR_EMAIL`. An action that sends that placeholder to another\n * service asks it to find a user that does not exist, so the request can\n * only fail \u2014 and it fails at the END, after other people spent their\n * approvals on it. A use case that carries the creator into its call sets\n * this, and `canCreate` then refuses such a caller at the start.\n *\n * `extend-trial` and `send-to-expired-trial` are two: both send the creator\n * to accounts in an `ApprovalM2mActor`, and accounts reads the permissions\n * of that person again before it acts.\n *\n * `move-cluster-org` does NOT set this. Its envelope reaches console, which\n * reads the user id alone and needs no address.\n */\n creatorEmailRequired?: boolean\n /**\n * A use case with no approval that any signed-in person can open AND run.\n *\n * A request with a floor of zero runs when it is opened, so `canCreate`\n * asks the person to hold `requiredPermission`. This flag lifts that one\n * rule, for an action whose run needs no permission of the person. Set it\n * only when two things hold:\n *\n * - Something else bounds how often a person can ask.\n * - The service that runs the action authorizes the person again at its\n * source, so that nobody can change what is not theirs. The action can\n * touch an organization only when that check lets the person change it.\n *\n * `create-personal-organisation` and the two use cases of custom hardware\n * sizing set it. The two use cases of custom hardware sizing do the second\n * check in three ways:\n *\n * - Creating a plan, and unlinking a plan from one organization, read the\n * role of the person in that organization. Only an OWNER passes.\n * - Linking an existing plan to an organization reads the same role.\n * Only an OWNER passes. The dedupe key is the only bound on how often a\n * person can ask for a link.\n * - Deleting a plan reads no role. Console checks that the person created\n * the plan. Accounts reads the organizations that the person is a member\n * of, in any role, and removes the plan from their lists.\n */\n openToEveryone?: boolean\n /**\n * A use case with no approval that only a service of the fleet opens.\n *\n * The service holds `createPermission`, an m2m permission, and not\n * `requiredPermission`. This flag lets that holder open and thus run a\n * request with a floor of zero. `requiredPermission` then decides only who\n * sees the requests and who can run a failed one again. Without a\n * `createPermission` the flag lifts nothing.\n *\n * `cluster-ttl-deletion` sets it. The request exists for the record: the\n * list, the entity pages and the audit trail show each deletion.\n */\n openedBySystem?: boolean\n}\n"],
|
|
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 wire types of the generic approval mechanism.\n *\n * This file holds no logic and no dependency, because three consumers read it:\n * control-backend, control-frontend and cloudbot. The rules that read these\n * types are in `../approvals/policy.js`, and they are pure for the same reason.\n */\n\n/**\n * Every state that an approval request can hold.\n *\n * A `const` tuple, not an enum, so it is both the union type and the list that\n * validates a status filter at run time.\n *\n * The path of a request: `pending` until it reaches the threshold. Then\n * `executing` (automatic mode) or `approved` (explicit mode, which waits for a\n * person to press the button). `executed` or `failed` after the action ran. A\n * request that never reaches the threshold ends `rejected`, `withdrawn` or\n * `expired`.\n *\n * `scheduled` is a request that the service opened by itself, and that runs\n * at `runAt` with no approval. It does not expire, and nobody can withdraw or\n * reject it. A holder of its permissions can run it before `runAt`.\n */\nexport const APPROVAL_JOB_STATUSES = [\n 'pending',\n 'scheduled',\n 'approved',\n 'executing',\n 'executed',\n 'failed',\n 'rejected',\n 'withdrawn',\n 'expired',\n] as const\n\nexport type ApprovalJobStatus = (typeof APPROVAL_JOB_STATUSES)[number]\n\n/**\n * The states in which the action of a request is still COMING.\n *\n * `pending` waits for approvals, `scheduled` waits for its time, `approved`\n * waits for a person to press the button, and `executing` is running now. Each of them can still change the\n * world, so two requests for one target must not be in these states together.\n * `dedupeKey` reads this set for that reason.\n *\n * `failed` is NOT here, although a person can run a failed request again. A\n * failed request runs only when a person presses the button, and that person\n * sees the row. Blocking a new request on a failed one would also trap the\n * key: `canWithdraw` accepts `pending` alone, so nobody could close it.\n *\n * `executed`, `rejected`, `withdrawn` and `expired` are the end of a request.\n */\nexport const APPROVAL_LIVE_STATUSES = [\n 'pending',\n 'scheduled',\n 'approved',\n 'executing',\n] as const satisfies readonly ApprovalJobStatus[]\n\n/**\n * How many requests one page of the list holds when the caller asks for no\n * number.\n *\n * The list is a work queue that a Camundi reads, so the page is large: a\n * person wants the whole queue and not ten rows at a time. What the number is\n * for is the ceiling below.\n */\nexport const APPROVAL_PAGE_SIZE_DEFAULT = 500\n\n/**\n * The largest page that the service builds, and the largest that a caller may\n * ask for. The API answers 400 above it.\n *\n * The list had no limit at all. One authenticated caller could then make the\n * service read the whole table into memory, serialize it, and do it again, so\n * the cost of a request grew with the age of the service and with nothing that\n * the caller had to pay for. A ceiling turns that into a fixed cost.\n *\n * It is HERE and not only in the service, because the view must know it: a\n * page that asked for more would get a 400 instead of rows.\n */\nexport const APPROVAL_PAGE_SIZE_MAX = 2000\n\n/**\n * The columns that the queue can be ordered by.\n *\n * A NAME and not an SQL expression: the repository maps each one, so a caller\n * can never name a column that this list does not hold. `status` orders by the\n * EFFECTIVE status, because a `pending` row whose time ran out reads as\n * `expired` everywhere else.\n */\nexport const APPROVAL_SORT_FIELDS = [\n 'createdAt',\n 'expiresAt',\n 'status',\n 'type',\n 'createdBy',\n] as const\n\nexport type ApprovalSortField = (typeof APPROVAL_SORT_FIELDS)[number]\n\nexport type ApprovalSortOrder = 'asc' | 'desc'\n\n/** The default order of the queue: the newest request first. */\nexport const APPROVAL_SORT_DEFAULT: {\n sort: ApprovalSortField\n order: ApprovalSortOrder\n} = { sort: 'createdAt', order: 'desc' }\n\n/**\n * The longest reason that a request, a rejection or a withdrawal accepts.\n *\n * ONE number, because three routes of control-backend hold it and three fields\n * of control-frontend must stop there. A field that let a person write more\n * than the schema accepts turns their sentence into a 400 after they wrote it.\n */\nexport const APPROVAL_REASON_MAX_LENGTH = 2000\n\n/**\n * The largest number of approvals that a caller can ask for.\n *\n * A request that asks for 400 approvals can never reach them, so it is a\n * request that only fills the table and expires. The ceiling turns that into a\n * 400 at the moment a person types it. It is HERE because the form of the\n * bulk operation must stop at the same number as the route.\n */\nexport const APPROVAL_ACKS_MAX = 10\n\n/**\n * The longest search text that the list accepts.\n *\n * A search runs `ILIKE` over the payload of every visible row, so the cost of\n * one call follows the length of the text. Nothing a person types to find a\n * request is near this.\n */\nexport const APPROVAL_SEARCH_MAX_LENGTH = 200\n\n/**\n * APPROVAL_TYPE_FILTER_MAX is the most use case types that one `type` filter\n * names. The registry holds fewer, so the ceiling only stops a list with no\n * end. control-backend answers 400 above it.\n */\nexport const APPROVAL_TYPE_FILTER_MAX = 100\n\n/** APPROVAL_TYPE_MAX_LENGTH is the longest type that a `type` filter names. */\nexport const APPROVAL_TYPE_MAX_LENGTH = 200\n\n/**\n * The payload field that names the organization a request acts on.\n *\n * It is a CONVENTION, and this constant is what makes the convention readable\n * from both sides. A use case whose action hits one organization puts the id of\n * that organization in this field, and `GET /api/approvals?orgId=\u2026` then finds\n * the request with no knowledge of the use case. The filter reads this one\n * field of the payload, so it matches the whole id and nothing else. The free\n * text search reads the payload as TEXT, which also matches an id that only\n * appears inside another field, and it cannot answer \"the requests of this\n * organization\".\n *\n * The organization page of control-frontend does NOT read this field. It asks\n * the entity-link filter (`entityType=org`), which reads a row that\n * `relatedEntities` of a use case writes \u2014 see `ApprovalEntityRef`. The summary\n * is separate again: the `org` field of a `summarize` result is what a READER\n * follows.\n */\nexport const APPROVAL_PAYLOAD_ORG_FIELD = 'orgId'\n\n/**\n * How many requests each status and each use case type holds, for the filters\n * of the queue.\n *\n * Every status number answers the SAME question as the list beside it: the\n * counts are built with the visibility rule, the action filter and the search\n * text of the caller, and with the lazy expiry. A count that ignored one of\n * those would send a person to a filter that then shows nothing. `byType` is\n * the exception: see its own note.\n */\nexport interface ApprovalStatusCountsDto {\n /** Every request that matches, whatever its status. */\n all: number\n /** One number for each status. A status with no request is 0, never absent. */\n byStatus: Record<ApprovalJobStatus, number>\n /**\n * One number for each use case type, over every status.\n *\n * It ignores the type filter, so a type facet keeps its number while it is\n * on or off. Every other filter applies. A type with no request has no key,\n * so read a missing key as 0, and read only an own key: a type id such as\n * `__proto__` must not reach the prototype.\n *\n * Optional, because an older control-backend does not send it. Read an\n * absent field as no counts.\n */\n byType?: Record<string, number>\n}\n\n/**\n * What happens when a request reaches its threshold.\n *\n * `auto` runs the action immediately. `explicit` holds the request at\n * `approved` and waits for a person to run it.\n */\nexport type ApprovalExecutionMode = 'auto' | 'explicit'\n\n/**\n * WHERE the action runs. It is a separate question from who starts it.\n *\n * - `inline` runs the action inside the call that started it, and the answer\n * already carries the outcome. Right for one call to another service.\n * - `background` answers as soon as the request is claimed, and the action\n * runs on. It reports into `ApprovalJobDto.progress`, and the row is the\n * only place that holds the progress, so a reload and a second person read\n * the same numbers. A pod that stops loses the run, and the reconciler of\n * control-backend recovers the row.\n *\n * An action over thousands of targets MUST be `background`. `inline` would\n * hold one HTTP request for the whole run, and the ingress, the load balancer\n * and the browser each have their own deadline before it finishes.\n *\n * An `inline` action must not read `context.signal`. The runner trusts the\n * result of an `inline` action that returns after a drain, and it would count\n * a partial run as `executed`. An action that stops between targets is\n * `background`.\n */\nexport type ApprovalActionMode = 'inline' | 'background'\n\n/**\n * A person, as an approval request records them.\n *\n * Both keys are kept. The machine then never has to choose one identity key,\n * which is what split the earlier acknowledgement flows: some held an email and\n * some held a user id, and the two could not be compared.\n */\nexport interface ApprovalActor {\n userId: string\n email: string\n}\n\nexport interface ApprovalAck extends ApprovalActor {\n /** An ISO timestamp. It is in a json column, so it is a string, not a Date. */\n date: string\n}\n\n/**\n * Who asked for an action, and who agreed to it.\n *\n * Every route of `api/admin/approvals` takes this envelope, and control-backend\n * builds it from the request that it runs. The envelope exists because the\n * token cannot carry it: the call is machine to machine, so `sub` holds the id\n * of the Auth0 application and not a person. A service that reads the token\n * alone therefore writes \"control-backend\" into its audit record, which hides\n * the two Camundis that the whole mechanism exists to record.\n *\n * `approvedBy` is a list, because a use case can need more than one approval,\n * and the record must name all of them. It can also be EMPTY: how many\n * approvals an action needs is a number of the use case, and `move-cluster-org`\n * holds zero on purpose. The person in `requestedBy` is never in the list: the\n * four-eyes rule is that the person who asks is not the person who agrees, and\n * control-backend holds that rule.\n *\n * Each person carries both keys of `ApprovalActor`. The email is what a route\n * uses to ask Auth0 about the person, and the user id is what an audit record\n * holds, because every other record of the fleet holds an Auth0 `sub`.\n *\n * CAUTION: The envelope is a CLAIM of the caller, and not proof. A route that\n * acts on it must ask Auth0 again whether `requestedBy` may do the action.\n * That means the permission of the action. For a use case with\n * `openToEveryone`, it means that the person is a Camundi, because no\n * permission runs that action. `api/admin/approvals` in accounts-backend does\n * that. See the note on `M2M_CONTROL_APPROVALS`.\n */\nexport interface ApprovalM2mActor {\n /** The id of the approval request, to join the records of both services. */\n requestId: string\n /** The Camundi who opened the request. */\n requestedBy: ApprovalActor\n /** The Camundis who approved it. Empty when the use case needs none. */\n approvedBy: ApprovalActor[]\n /**\n * The Camundi who ran the request with an approval override grant, in\n * place of the approvals that it still needed. Absent for every other run.\n *\n * control-backend checked the grant, the permissions of this person and\n * `APPROVAL_OVERRIDE_DENYLIST` before the run. A receiver that counts the\n * approvals of an overridable type accepts this person in place of the\n * approvals that are missing, and refuses this field for every other type.\n * This person can be the person in `requestedBy`, because that is what a\n * grant is for. A receiver that refuses the target of an action as an\n * approver refuses this person as well, also when they asked.\n */\n overriddenBy?: ApprovalActor\n}\n\n/**\n * What one value of a summary field is.\n *\n * `text` is what a person reads. `id` is what a link points at, and it is\n * absent when the kind names no entity. The two are separate because the text\n * is a COPY that the request keeps: an organization that gets a new name after\n * the request was opened must still read as the organization that the person\n * chose.\n */\nexport interface ApprovalSummaryValue {\n text: string\n id?: string\n}\n\n/**\n * How a reader must render one field of a summary.\n *\n * `org`, `cluster` and `user` name an entity, so a reader can link to it and\n * can resolve a fresh name. The others are plain: `date` is an ISO timestamp,\n * and `code` is a value that must keep its characters, such as a json fragment.\n */\nexport const APPROVAL_SUMMARY_FIELD_KINDS = [\n 'text',\n 'number',\n 'boolean',\n 'date',\n 'org',\n 'cluster',\n 'user',\n 'code',\n] as const\n\nexport type ApprovalSummaryFieldKind =\n (typeof APPROVAL_SUMMARY_FIELD_KINDS)[number]\n\n/**\n * One labelled field of the payload of a request.\n *\n * This is what makes ONE view able to show EVERY use case. The payload of a\n * request is an object of anything, so a generic view can only print it as\n * json. The use case turns it into labelled fields here, and the view renders\n * a field by its kind: it links an `org`, it formats a `date`, and it lists\n * 300 clusters as 300 links.\n *\n * `values` is always an array. A field that holds one value holds an array of\n * one, so a reader needs no branch for \"one or many\".\n */\nexport interface ApprovalSummaryField {\n label: string\n kind: ApprovalSummaryFieldKind\n values: ApprovalSummaryValue[]\n /**\n * The values that this request replaces, for a field that changes. A view\n * shows them as removed beside `values` as added. A field with no\n * `previous` is not a change.\n */\n previous?: ApprovalSummaryValue[]\n /** The count of `previous`, when the list cut it. Like `total`, for `previous`. */\n previousTotal?: number\n /**\n * How many values the field holds, when `values` holds only the first of\n * them. The list route cuts every field to `APPROVAL_LIST_SUMMARY_VALUES_MAX`\n * values. The detail route sends every value and no `total`.\n */\n total?: number\n}\n\n/**\n * How many values of one summary field a row of the list carries.\n *\n * A bulk operation names up to 3000 clusters in one field. A page of 100 such\n * rows would carry 300,000 values that no row shows. The view shows at most\n * 12 values of a field, so the list stops there, and `total` holds the count.\n */\nexport const APPROVAL_LIST_SUMMARY_VALUES_MAX = 12\n\n/**\n * The kinds of entity that an approval request can concern.\n *\n * These are the entities that a request is findable by: the org and cluster\n * detail pages ask \"which requests touch me\", and the answer reads a row for\n * each entity. It is a subset of `APPROVAL_SUMMARY_FIELD_KINDS` \u2014 the kinds that\n * name an entity \u2014 because only those carry an id that a page can match.\n */\nexport const APPROVAL_ENTITY_TYPES = ['cluster', 'org', 'user'] as const\n\nexport type ApprovalEntityType = (typeof APPROVAL_ENTITY_TYPES)[number]\n\n/**\n * One entity that an approval request concerns.\n *\n * A use case reports these from its payload, and the service writes one row for\n * each into `approval_job_entity`. That row is what makes a request findable by\n * an entity it touches, without a text search over the payload: a cluster-move\n * request names its cluster, its target organization AND its source\n * organization, so it shows on all three detail pages. The `summary` still\n * carries the words a reader sees; this carries only the ids a page matches.\n */\nexport interface ApprovalEntityRef {\n type: ApprovalEntityType\n id: string\n}\n\n/**\n * How long a run may go quiet before a reader calls it stalled.\n *\n * The pod that runs an action writes its progress and renews its lease on one\n * timer, so a healthy run touches the row well inside this window. A row that\n * is `executing` and older than this held a pod that stopped, and the\n * reconciler of control-backend is on its way to it.\n *\n * It lives here, and not in the config of the service, because a READER needs\n * it: the browser must decide what to show without a second call, and a number\n * that only the backend knew would make the two disagree. `ACTION_LEASE_MS` in\n * `config/index.ts` is held below this on purpose.\n */\nexport const APPROVAL_RUN_STALE_MS = 120_000\n\n/**\n * What an action reports while it runs.\n *\n * This is what makes a long action watchable. Every field is in the DATABASE\n * and not in the answer to the call that started the run, so three readers see\n * the same thing: the person who pressed the button, the same person after a\n * reload, and a second person who opens the request.\n *\n * `total` can be 0 while an action counts its targets, and it stays 0 for an\n * action that cannot count them. A reader must then show that it runs, and no\n * percentage.\n */\nexport interface ApprovalProgress {\n /** Targets in total. 0 means the action did not say. */\n total: number\n /** Targets finished, whatever the outcome of each one. */\n done: number\n /** Targets that finished with an error. Part of `done`. */\n failed: number\n /** One short line for a person, such as the step that runs now. */\n note?: string\n /** An ISO timestamp: when the run started. */\n startedAt: string\n /** An ISO timestamp: when the run last wrote anything. */\n updatedAt: string\n}\n\n/** The share of the work that is finished, or `undefined` with no total. */\nexport function progressFraction(\n progress: ApprovalProgress | undefined,\n): number | undefined {\n if (!progress || progress.total <= 0) {\n return undefined\n }\n return Math.min(1, progress.done / progress.total)\n}\n\n/**\n * Reports whether a running request went quiet.\n *\n * True means that the pod which held the run stopped, or lost the row. It does\n * NOT mean that the action failed: the reconciler either runs it again, or it\n * writes `failed` so that a person can. See `APPROVAL_RUN_STALE_MS`.\n */\nexport function isRunStalled(job: ApprovalJobDto, now: Date): boolean {\n if (job.status !== 'executing') {\n return false\n }\n const lastSign = job.progress?.updatedAt ?? job.executingSince\n if (!lastSign) {\n return false\n }\n return now.getTime() - new Date(lastSign).getTime() > APPROVAL_RUN_STALE_MS\n}\n\n/** One approval request, as the API returns it. */\nexport interface ApprovalJobDto {\n id: string\n type: string\n dedupeKey?: string\n payload: Record<string, unknown>\n /**\n * The payload as labelled fields. See `ApprovalSummaryField`.\n *\n * The use case builds it when the request is opened AND every time the\n * request is read, so a field that a later version of the use case adds\n * appears on a request that is already open.\n */\n summary: ApprovalSummaryField[]\n /** Why the person opened the request. Some use cases demand one. */\n reason?: string\n createdBy: ApprovalActor\n acks: ApprovalAck[]\n /**\n * How many approvals THIS request needs.\n *\n * It is on the request and not only on the use case, because the person who\n * opens one can ask for more than the floor of the use case. A bulk\n * operation over 3000 clusters is the same action as one over 3, and it can\n * still deserve a third pair of eyes. `ApprovalUseCaseDto.minAcks` is the\n * floor that a caller cannot go below.\n */\n requiredAcks: number\n /**\n * What a person must hold to approve, reject or run THIS request.\n *\n * It is on the request and not only on the use case, because a payload can\n * raise the guard: a generation update with `yoloForceUpdate` needs the yolo\n * permission on top of the base one, and the same use case without the flag\n * does not. The set is computed ONE time, when the request is opened, so it\n * is also the record of what the request was guarded by.\n *\n * A person needs EVERY permission in the set, not one of them.\n */\n requiredPermissions: string[]\n executionMode: ApprovalExecutionMode\n actionMode: ApprovalActionMode\n status: ApprovalJobStatus\n closedBy?: ApprovalActor\n closedReason?: string\n /** An ISO timestamp. */\n expiresAt: string\n /**\n * An ISO timestamp: when the current run took the request.\n *\n * It is set every time a run starts, so a run that the reconciler starts\n * again moves it. `attempts` says how many runs there were.\n */\n executingSince?: string\n /** How many times a run started. A recovery raises it. */\n attempts: number\n /**\n * What the action reports while it runs. See `ApprovalProgress`.\n *\n * It stays on the row after the run, so the record of a finished run says\n * how many targets it touched and how many refused.\n */\n progress?: ApprovalProgress\n executedAt?: string\n executionError?: string\n /**\n * What the action reported when it ran.\n *\n * The shape belongs to the use case. An action over many targets puts the\n * result of each target here, which is the difference between \"it failed\"\n * and \"it did 2998 of 3000, and these two refused\".\n */\n executionResult?: Record<string, unknown>\n /** An ISO timestamp: when a `scheduled` request runs by itself. */\n runAt?: string\n /**\n * The person who ran this request with an approval override grant, in\n * place of the approvals that it still needed. See `canOverride`.\n */\n overriddenBy?: ApprovalActor\n /**\n * An ISO timestamp: when that person used the override. The run window of\n * an overridden request starts here. See `runWindowEnd`.\n */\n overriddenAt?: string\n /** Why that person used the override. It is necessary. */\n overrideReason?: string\n /**\n * The approval override grant that the override used. A later run needs\n * this grant to be active still. See `canRunOverridden`.\n */\n overrideGrantId?: string\n createdAt: string\n updatedAt: string\n}\n\n/**\n * One approval override grant, as the API returns it.\n *\n * A grant lets one Camundi run a `pending` request of an overridable use case\n * with no approval, until `expiresAt`. A grant is active while `revokedAt` is\n * absent and `expiresAt` is in the future. See `isOverrideGrantActive`.\n */\nexport interface ApprovalOverrideGrantDto {\n id: string\n /** The Auth0 `sub` of the person who holds the grant. */\n userId: string\n email: string\n /** The `grant-approval-override` request that made this grant. */\n grantedByJobId: string\n /** The `revoke-approval-override` request that ends this grant. */\n revokeJobId?: string\n reason: string\n /** An ISO timestamp. */\n createdAt: string\n /** An ISO timestamp. The grant ends here, also when no revoke ran. */\n expiresAt: string\n /** An ISO timestamp: when the revoke request ran. */\n revokedAt?: string\n revokedByJobId?: string\n}\n\n/**\n * What a use case is, as the API reports it.\n *\n * The frontend needs `requiredPermission` to run the same rule as the backend.\n * PR #9301 kept this registry a second time, by hand, in the frontend. A\n * registry that the server owns means a new use case needs no frontend deploy.\n */\nexport interface ApprovalUseCaseDto {\n type: string\n /** A short name for a person to read. */\n label: string\n /** One sentence: what the action does, and what it cannot undo. */\n description: string\n /** The approvals a new request asks for when the caller names no number. */\n defaultAcks: number\n /**\n * The floor. A caller can ask for more approvals, never for fewer.\n *\n * It is a separate number from `defaultAcks` because the two answer\n * different questions: the default is what the form offers, and the floor is\n * what the service refuses to go below.\n */\n minAcks: number\n ttlDays: number\n executionMode: ApprovalExecutionMode\n /** See `ApprovalActionMode`. An action over many targets is `background`. */\n actionMode: ApprovalActionMode\n /**\n * True when a run that was interrupted can start again by itself.\n *\n * It is a promise about the ACTION, and the reconciler reads it. Set it only\n * when the action can run a second time over the same payload and do no harm\n * twice: it must read what the first run already finished, out of\n * `ApprovalJobDto.executionResult`, and skip it.\n *\n * False, the default, is the safe answer. An interrupted run then lands in\n * `failed`, which a person can run again from the detail page, and a person\n * decides whether running it twice is safe.\n */\n resumable: boolean\n /**\n * The permission that every request of this use case needs to approve,\n * reject or run. A payload can add more. See\n * `ApprovalJobDto.requiredPermissions`, which is the effective set.\n *\n * It is also the permission to SEE a request of this use case, because a\n * person who cannot act on one has no reason to read it. The person who\n * opened a request always sees that request.\n *\n * The one exception to \"or run\" is a use case with `openToEveryone`. It has\n * no approval, so this permission only decides who sees the requests of\n * other people. Any signed-in person can open and run such a request.\n */\n requiredPermission: string\n /**\n * The permission to open a request. Absent means any signed-in Camundi.\n * A list means that any one of its permissions is enough. An empty list\n * opens the request to nobody, so give a list at least one entry.\n *\n * Set it when the pool that can ASK must be as small as the pool that can\n * approve. Leave it absent when the approvals alone are the guard, and the\n * proposal is not. Read it with `createPermissionsOf`.\n */\n createPermission?: string | string[]\n /** True when a request cannot be opened without a reason. */\n reasonRequired: boolean\n /**\n * True when the ACTION needs the email address of the person who asked.\n *\n * A token can carry no address, and the row then stores\n * `UNKNOWN_ACTOR_EMAIL`. An action that sends that placeholder to another\n * service asks it to find a user that does not exist, so the request can\n * only fail \u2014 and it fails at the END, after other people spent their\n * approvals on it. A use case that carries the creator into its call sets\n * this, and `canCreate` then refuses such a caller at the start.\n *\n * `extend-trial` and `send-to-expired-trial` are two: both send the creator\n * to accounts in an `ApprovalM2mActor`, and accounts reads the permissions\n * of that person again before it acts.\n *\n * `move-cluster-org` does NOT set this. Its envelope reaches console, which\n * reads the user id alone and needs no address.\n */\n creatorEmailRequired?: boolean\n /**\n * A use case with no approval that any signed-in person can open AND run.\n *\n * A request with a floor of zero runs when it is opened, so `canCreate`\n * asks the person to hold `requiredPermission`. This flag lifts that one\n * rule, for an action whose run needs no permission of the person. Set it\n * only when two things hold:\n *\n * - Something else bounds how often a person can ask.\n * - The service that runs the action authorizes the person again at its\n * source, so that nobody can change what is not theirs. The action can\n * touch an organization only when that check lets the person change it.\n *\n * `create-personal-organisation` and the two use cases of custom hardware\n * sizing set it. The two use cases of custom hardware sizing do the second\n * check in three ways:\n *\n * - Creating a plan, and unlinking a plan from one organization, read the\n * role of the person in that organization. Only an OWNER passes.\n * - Linking an existing plan to an organization reads the same role.\n * Only an OWNER passes. The dedupe key is the only bound on how often a\n * person can ask for a link.\n * - Deleting a plan reads no role. Console checks that the person created\n * the plan. Accounts reads the organizations that the person is a member\n * of, in any role, and removes the plan from their lists.\n */\n openToEveryone?: boolean\n /**\n * A use case with no approval that only a service of the fleet opens.\n *\n * The service holds `createPermission`, an m2m permission, and not\n * `requiredPermission`. This flag lets that holder open and thus run a\n * request with a floor of zero. `requiredPermission` then decides only who\n * sees the requests and who can run a failed one again. Without a\n * `createPermission` the flag lifts nothing.\n *\n * `cluster-ttl-deletion` sets it. The request exists for the record: the\n * list, the entity pages and the audit trail show each deletion.\n */\n openedBySystem?: boolean\n}\n"],
|
|
5
5
|
"mappings": "AA8BO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAmBO,MAAM,yBAAyB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAUO,MAAM,6BAA6B;AAcnC,MAAM,yBAAyB;AAU/B,MAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,MAAM,wBAGT,EAAE,MAAM,aAAa,OAAO,OAAO;AAShC,MAAM,6BAA6B;AAUnC,MAAM,oBAAoB;AAS1B,MAAM,6BAA6B;AAOnC,MAAM,2BAA2B;AAGjC,MAAM,2BAA2B;AAoBjC,MAAM,6BAA6B;AAqJnC,MAAM,+BAA+B;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AA4CO,MAAM,mCAAmC;AAUzC,MAAM,wBAAwB,CAAC,WAAW,OAAO,MAAM;AAgCvD,MAAM,wBAAwB;AA8B9B,SAAS,iBACd,UACoB;AACpB,MAAI,CAAC,YAAY,SAAS,SAAS,GAAG;AACpC,WAAO;AAAA,EACT;AACA,SAAO,KAAK,IAAI,GAAG,SAAS,OAAO,SAAS,KAAK;AACnD;AASO,SAAS,aAAa,KAAqB,KAAoB;AACpE,MAAI,IAAI,WAAW,aAAa;AAC9B,WAAO;AAAA,EACT;AACA,QAAM,WAAW,IAAI,UAAU,aAAa,IAAI;AAChD,MAAI,CAAC,UAAU;AACb,WAAO;AAAA,EACT;AACA,SAAO,IAAI,QAAQ,IAAI,IAAI,KAAK,QAAQ,EAAE,QAAQ,IAAI;AACxD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export { isSameApprovalValue, staleApprovalKeys, } from './approvals/expected-va
|
|
|
32
32
|
export type { ClusterPlanTypeClusterDto, ClusterPlanTypeClustersDto, ForceDeleteClusterPlanTypeStepResult, } from './approvals/force-delete-cluster-plan-type.js';
|
|
33
33
|
export { FORCE_DELETE_CLUSTER_PLAN_TYPE_MAX_CLUSTERS } from './approvals/force-delete-cluster-plan-type.js';
|
|
34
34
|
export type { ApprovalActorView, ApprovalDecision, ApprovalDenialCode, } from './approvals/policy.js';
|
|
35
|
-
export { approvalPermissionCheck, canAcknowledge, canCreate, canExecute, canOverride, canReject, canRunOverridden, canView, canWithdraw, EXECUTE_WINDOW_DAYS, effectiveStatus, hasActorEmail, hasEveryPermission, hasFailedRun, isExpired, isOverrideGrantActive, isRunWindowClosed, isSameActor, isSameActorEmail, needsReason, OVERRIDE_GRANT_ENDED_MESSAGE, opensWithOwnApproval, overrideGranteeOf, remainingAcks, requiredPermissionsOf, resolveRequiredAcks, runWindowEnd, } from './approvals/policy.js';
|
|
35
|
+
export { approvalPermissionCheck, canAcknowledge, canCreate, canExecute, canOverride, canReject, canRunOverridden, canView, canWithdraw, createPermissionsOf, EXECUTE_WINDOW_DAYS, effectiveStatus, hasActorEmail, hasEveryPermission, hasFailedRun, isExpired, isOverrideGrantActive, isRunWindowClosed, isSameActor, isSameActorEmail, needsReason, OVERRIDE_GRANT_ENDED_MESSAGE, opensWithOwnApproval, overrideGranteeOf, remainingAcks, requiredPermissionsOf, resolveRequiredAcks, runWindowEnd, } from './approvals/policy.js';
|
|
36
36
|
export type { ReleaseTrainAutoUpgradeCandidates, ReleaseTrainPublishPayload, ReleaseTrainRollbackPayload, } from './approvals/release-train.js';
|
|
37
37
|
export type { ApprovalUseCaseType } from './approvals/use-cases.const.js';
|
|
38
38
|
export { APPROVAL_EMAIL_MAX_LENGTH, APPROVAL_EMAIL_PATTERN, APPROVAL_OVERRIDE_DEFAULT_HOURS, APPROVAL_OVERRIDE_DENYLIST, APPROVAL_OVERRIDE_MAX_HOURS, APPROVAL_USE_CASE_TYPES, ENTERPRISE_ONBOARDING_METRIC_MAX, ENTERPRISE_ONBOARDING_NAME_MAX_LENGTH, ENTERPRISE_ONBOARDING_RESERVATION_MAX, ENTERPRISE_ONBOARDING_SFDC_ID_MAX_LENGTH, ENTERPRISE_ONBOARDING_TERM_MAX_MONTHS, ENTERPRISE_ONBOARDING_TERM_MIN_MONTHS, EXTEND_TRIAL_MAX_DAYS, EXTEND_TRIAL_MIN_DAYS, GENERATION_VERSION_VALUE_MAX_LENGTH, GENERATION_VERSIONS_MAX_GENERATIONS, isApprovalOverridable, isCalendarDay, isLlmSpendBelowTopUpThreshold, LLM_BUDGET_TOP_UP_THRESHOLD, ORG_LIMIT_TEXT_MAX_LENGTH, PERSONAL_ORGANIZATION_MAX_PER_REQUESTER, PERSONAL_ORGANIZATION_NAME_MAX_LENGTH, PERSONAL_ORGANIZATION_NAME_MIN_LENGTH, PLATFORM_ROLE_CHANGE_MAX_ROLES, PURGE_USERS_MAX_USERS, PURGE_USERS_REQUIRED_PERMISSIONS, REMOVE_ORG_MEMBER_REQUIRED_PERMISSIONS, USER_METADATA_EXPECTED_MAX_LENGTH, USER_METADATA_MAX_LENGTH, } from './approvals/use-cases.const.js';
|
|
@@ -142,7 +142,7 @@ export type { ClusterUsageDataRequest, ClusterUsageMetadataDto, ClusterUsageMetr
|
|
|
142
142
|
export type { DashboardConfig, DashboardMappingItem, DashboardMappingTarget, DashboardResultPrimitive, DashboardResultRow, EngineeringOperationsDashboardResponse, } from './types/Dashboard.types.js';
|
|
143
143
|
export type { EnterpriseIncludingValueMetrics } from './types/EnterpriseIncludingValueMetrics.type.js';
|
|
144
144
|
export type { HdpTenantUsageBreakdown, HdpTenantUsagePerClusterPoint, HdpTenantUsagePoint, } from './types/HdpTenantUsage.type.js';
|
|
145
|
-
export type { AlertSubscription as HdpAlertSubscription,
|
|
145
|
+
export type { AlertSubscription as HdpAlertSubscription, ByomClient as HdpByomClient, Channel as HdpChannel, Cluster as HdpCluster, ClusterBackup as HdpClusterBackup, ClusterBackupSchedule as HdpClusterBackupSchedule, ClusterBulkOperation as HdpClusterBulkOperation, ClusterClient as HdpClusterClient, ClusterModification as HdpClusterModification, ClusterPlan as HdpClusterPlan, ClusterPlanType as HdpClusterPlanType, ConnectorSecret as HdpConnectorSecret, ConsoleApiClient as HdpConsoleApiClient, Datacenter as HdpDatacenter, Generation as HdpGeneration, HdpBackendChangeEvent, HdpClusterModificationConfig, HdpDisk, HdpEntity, HdpPrometheusDataCapacityDataPoint, Invite as HdpInvite, LlmSpend as HdpLlmSpend, Organization as HdpOrganization, SecureConnectivity as HdpSecureConnectivity, SystemAudit as HdpSystemAudit, TelemetryData as HdpTelemetryData, UsageAlert as HdpUsageAlert, User as HdpUser, UserLogin as HdpUserLogin, } from './types/hdp.types.js';
|
|
146
146
|
export { AlertSubscriptionReportType as HdpAlertSubscriptionReportType, AlertSubscriptionType as HdpAlertSubscriptionType, HDP_DISKS, } from './types/hdp.types.js';
|
|
147
147
|
export type { GlobalNotification, NotificationDto, NotificationMeta, NotificationOptions, NotificationPermission, NotificationSource, NotificationState, NotificationType, OrgNotification, UserNotification, UserOrgNotification, } from './types/notification.type.js';
|
|
148
148
|
export { NotificationPermissionValues, NotificationSourceEnum, NotificationStateEnum, NotificationTypeEnum, } from './types/notification.type.js';
|
package/dist/index.js
CHANGED
|
@@ -204,6 +204,7 @@ import {
|
|
|
204
204
|
canRunOverridden,
|
|
205
205
|
canView,
|
|
206
206
|
canWithdraw,
|
|
207
|
+
createPermissionsOf,
|
|
207
208
|
EXECUTE_WINDOW_DAYS,
|
|
208
209
|
effectiveStatus,
|
|
209
210
|
hasActorEmail,
|
|
@@ -1043,6 +1044,7 @@ export {
|
|
|
1043
1044
|
computeOrgStartDate,
|
|
1044
1045
|
consoleUserFeaturesFromPermissions,
|
|
1045
1046
|
countBulkOperationAcks,
|
|
1047
|
+
createPermissionsOf,
|
|
1046
1048
|
dashboardDefinitionBytes,
|
|
1047
1049
|
dashboardDefinitionFits,
|
|
1048
1050
|
dashboardTileDefaultSize,
|