Restrict board interventions to board-authority principals

Any employee in the org could send a message as the board (freeze, takeover,
force-approval), because requireSender only checked that the board sender was a
valid employee. Enforce the approval_decider role (org_admin inherits it) via
IdentityAccessService, which also records an access decision for denied
attempts, and only render the thread board-control form for principals who hold
that authority — others get a read-only view of past interventions.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-28 19:44:35 -04:00
1 parent f2a1e71af7
commit 43e1626f77
3 files changed
+33

No files matched your search

@@ -10,6 +10,7 @@ import {
isRootMessage,
replayEventsForThread,
} from "./chat/chat-helpers";
import { ROLE_KEY } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
@@ -52,6 +53,17 @@ export function ChatPage({ snapshot, actions }: PageProps) {
);
const channels = useMemo(() => spaces.filter((space) => space.spaceType !== "direct"), [spaces]);
const directs = useMemo(() => spaces.filter((space) => space.spaceType === "direct"), [spaces]);
const canBoardIntervene = useMemo(
() =>
snapshot.roleGrants.some(
(grant) =>
grant.orgId === orgId &&
grant.principalType === "employee" &&
grant.principalId === employee.id &&
(grant.roleKey === ROLE_KEY.APPROVAL_DECIDER || grant.roleKey === ROLE_KEY.ORG_ADMIN),
),
[snapshot.roleGrants, orgId, employee.id],
);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
@@ -243,6 +255,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
files={files}
auditEvents={threadAuditEvents}
context={{ employees, agents }}
canIntervene={canBoardIntervene}
onClose={() => setOpenThreadId(null)}
onSubmit={sendInThread}
onBoardSubmit={sendBoardIntervention}
@@ -25,6 +25,7 @@ export function ChatThreadPanel({
files,
auditEvents,
context,
canIntervene,
onClose,
onSubmit,
onBoardSubmit,
@@ -39,6 +40,7 @@ export function ChatThreadPanel({
files: FileObjectRecord[];
auditEvents: AuditEventRecord[];
context: MessageContext;
canIntervene: boolean;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onBoardSubmit: (event: FormEvent<HTMLFormElement>) => void;
@@ -67,6 +69,7 @@ export function ChatThreadPanel({
thread={thread}
interventions={boardInterventions}
context={context}
canIntervene={canIntervene}
onSubmit={onBoardSubmit}
/>
{rootMessage && (
@@ -132,20 +135,24 @@ function BoardControl({
thread,
interventions,
context,
canIntervene,
onSubmit,
}: {
thread: ChatThreadRecord;
interventions: BoardInterventionRecord[];
context: MessageContext;
canIntervene: boolean;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
const [action, setAction] = useState("decision");
if (!canIntervene && interventions.length === 0) return null;
return (
<section style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 10, background: "var(--panel)", marginBottom: 16 }}>
<div className="row" style={{ marginBottom: 8 }}>
<div className="section-label">Board</div>
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
</div>
{canIntervene && (
<form onSubmit={onSubmit} style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<label className="field" style={{ margin: 0 }}>
<span>Action</span>
@@ -177,6 +184,7 @@ function BoardControl({
<Button tone="primary" size="small" type="submit"><Icon name="shield" size={12} /> Board</Button>
</div>
</form>
)}
{interventions.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 7, marginTop: 10 }}>
{interventions.map((intervention) => (