98 lines
3.7 KiB
TypeScript
98 lines
3.7 KiB
TypeScript
/**
|
|
* Copy contract for responsible-user ("on behalf of") authorization denials.
|
|
*
|
|
* When an agent run acts on behalf of a human user, authorization is the
|
|
* intersection of the agent's permissions and that user's permissions
|
|
* (see PAP-12447 / PAP-12459). When the intersection denies, the authz layer
|
|
* emits one of the codes below (`AuthorizationDecision.code` in
|
|
* `server/src/services/authorization.ts`). This module is the single source of
|
|
* truth for how those codes are explained to humans, so every surface that
|
|
* renders an agent-call failure uses consistent, actionable language.
|
|
*
|
|
* Terminology is deliberate: always "on behalf of {user}" / "responsible user",
|
|
* never "impersonate".
|
|
*/
|
|
|
|
export const RESPONSIBLE_USER_DENIAL_CODES = [
|
|
"RESPONSIBLE_USER_UNAUTHORIZED",
|
|
"RESPONSIBLE_USER_UNAVAILABLE",
|
|
] as const;
|
|
|
|
export type ResponsibleUserDenialCode = (typeof RESPONSIBLE_USER_DENIAL_CODES)[number];
|
|
|
|
export type ResponsibleUserDenialTone = "unauthorized" | "unavailable";
|
|
|
|
export interface ResponsibleUserDenialCopy {
|
|
code: ResponsibleUserDenialCode;
|
|
tone: ResponsibleUserDenialTone;
|
|
/** Short heading, e.g. for a banner title. */
|
|
title: string;
|
|
/** One or two sentences explaining what happened and why. */
|
|
description: string;
|
|
/** What the reader should do next. */
|
|
recommendedAction: string;
|
|
}
|
|
|
|
export function isResponsibleUserDenialCode(
|
|
code: string | null | undefined,
|
|
): code is ResponsibleUserDenialCode {
|
|
return (
|
|
code === "RESPONSIBLE_USER_UNAUTHORIZED" || code === "RESPONSIBLE_USER_UNAVAILABLE"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render a stable label for the responsible user. Falls back to a generic
|
|
* noun when the display name is unknown, so copy never shows a raw id.
|
|
*/
|
|
export function responsibleUserLabel(userName: string | null | undefined): string {
|
|
const trimmed = userName?.trim();
|
|
return trimmed && trimmed.length > 0 ? trimmed : "the responsible user";
|
|
}
|
|
|
|
/**
|
|
* Describe a responsible-user denial for display. `userName` is the responsible
|
|
* user's display name when known; when omitted, generic phrasing is used.
|
|
*
|
|
* These two codes are distinct from a plain agent-lacks-permission denial: here
|
|
* the *agent* is allowed but the *human this run acts for* is not (or is no
|
|
* longer available). Callers should keep the existing generic agent-permission
|
|
* copy for denials whose code is neither of these.
|
|
*/
|
|
export function describeResponsibleUserDenial(
|
|
code: ResponsibleUserDenialCode,
|
|
options: { userName?: string | null } = {},
|
|
): ResponsibleUserDenialCopy {
|
|
const who = responsibleUserLabel(options.userName);
|
|
|
|
if (code === "RESPONSIBLE_USER_UNAVAILABLE") {
|
|
return {
|
|
code,
|
|
tone: "unavailable",
|
|
title: "Responsible user unavailable",
|
|
description:
|
|
`This run acts on behalf of ${who}, but that account was removed or ` +
|
|
`deactivated, so its permissions can no longer be evaluated. The agent's ` +
|
|
`own permissions are not enough on their own — every action still requires ` +
|
|
`an active responsible user.`,
|
|
recommendedAction:
|
|
`Mark the work blocked and reassign a responsible user (or reactivate the ` +
|
|
`account) before the agent continues.`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
code,
|
|
tone: "unauthorized",
|
|
title: "Responsible user not authorized",
|
|
description:
|
|
`This action was denied because ${who} — the user this run acts on behalf ` +
|
|
`of — does not have permission to perform it. The agent may be allowed, but ` +
|
|
`a run can never exceed the permissions of the user it acts for, so the ` +
|
|
`action is blocked.`,
|
|
recommendedAction:
|
|
`Grant ${who} the required permission, or have someone who is authorized ` +
|
|
`take this action instead.`,
|
|
};
|
|
}
|