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:
1 parent
f2a1e71af7
commit
43e1626f77
3 files changed
+33
No files matched your search
@@ -5,12 +5,14 @@ import {
|
||||
CHAT_THREAD_BOARD_STATE,
|
||||
FILE_SCAN_STATE,
|
||||
RISK_LEVEL,
|
||||
ROLE_KEY,
|
||||
type FileScanState,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { buildChatMessageStructure } from "./chat-message-structure";
|
||||
import { ChatMessageMutationService } from "./chat-message-mutation-service";
|
||||
import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions";
|
||||
@@ -55,6 +57,7 @@ export class ChatService {
|
||||
private readonly boardInterventions: BoardInterventionService;
|
||||
private readonly handoffs: FileHandoffService;
|
||||
private readonly messageMutations: ChatMessageMutationService;
|
||||
private readonly identity: IdentityAccessService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
@@ -62,6 +65,7 @@ export class ChatService {
|
||||
this.boardInterventions = new BoardInterventionService(db);
|
||||
this.handoffs = new FileHandoffService(db);
|
||||
this.messageMutations = new ChatMessageMutationService(db);
|
||||
this.identity = new IdentityAccessService(db);
|
||||
}
|
||||
|
||||
createSpace(input: CreateChatSpaceInput): string {
|
||||
@@ -400,6 +404,14 @@ export class ChatService {
|
||||
}
|
||||
if (!senderId) throw new Error("Board sender is required.");
|
||||
this.requireOrgEntity("employees", senderId, orgId, "Board sender");
|
||||
this.identity.assertEmployeeRole(
|
||||
orgId,
|
||||
senderId,
|
||||
ROLE_KEY.APPROVAL_DECIDER,
|
||||
"chat_board_intervention",
|
||||
"organization",
|
||||
orgId,
|
||||
);
|
||||
return { type: "employee", id: senderId };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user