feat(permissions): unknown-sender request_approval flow + flipped default policy
When an unknown sender writes into a wired messaging group, surface the
situation to an admin instead of silently dropping. Flow:
1. Router → access gate → handleUnknownSender (policy='request_approval')
2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery
pick a reachable admin DM; deliver an Approve / Deny card; insert a
pending_sender_approvals row carrying the original InboundEvent JSON.
3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry
from the same stranger while pending is silently dropped, not re-carded.
4. Admin clicks → Chat SDK bridge → onAction → host response-registry.
The new handleSenderApprovalResponse in the permissions module claims
responses whose questionId matches a pending_sender_approvals row.
5. approve: addMember(stranger, agent_group) + replay the stored event via
routeInbound — the second attempt clears the gate because the user is
now known.
6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5
decision) — a future attempt triggers a fresh card.
Schema:
- Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id,
sender_identity, sender_name, original_message JSON, approver_user_id,
created_at, UNIQUE(mg_id, sender_identity)).
- Also flips messaging_groups.unknown_sender_policy default from 'strict'
to 'request_approval' (rebuild-table). Existing rows unchanged — only
the default applied to new rows flips.
- Router auto-create for unknown platform/chat drops the hardcoded
'strict' override; schema default applies.
- src/db/schema.ts reference updated to match.
Why default-flip: users wire their DM during setup and don't discover that
'strict' means "silent drop of everyone not in user_roles/members". The
approval flow is the safe default — the admin sees the stranger, explicitly
decides. 'public' stays opt-in for truly open channels.
Failure modes (row NOT created so a future attempt can try again):
- No eligible approver configured (fresh install before first owner).
- No reachable DM for any approver.
- Delivery adapter missing.
Tests (src/modules/permissions/sender-approval.test.ts, 4 cases):
- First unknown message → card delivered + row created
- Retry while pending → dedup'd (1 card, 1 row)
- Approve → member added + message replayed + container woken
- Deny → row cleared + no member added
Closes: ACTION-ITEMS item 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,16 +17,24 @@
|
||||
*/
|
||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
||||
import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
type InboundEvent,
|
||||
} from '../../router.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { addMember } from './db/agent-group-members.js';
|
||||
import {
|
||||
deletePendingSenderApproval,
|
||||
getPendingSenderApproval,
|
||||
} from './db/pending-sender-approvals.js';
|
||||
import { getUser, upsertUser } from './db/users.js';
|
||||
import { requestSenderApproval } from './sender-approval.js';
|
||||
|
||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||
let content: Record<string, unknown>;
|
||||
@@ -82,11 +90,12 @@ function handleUnknownSender(
|
||||
event: InboundEvent,
|
||||
): void {
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
const senderName = parsed.sender ?? null;
|
||||
const dropRecord = {
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
user_id: userId,
|
||||
sender_name: parsed.sender ?? null,
|
||||
sender_name: senderName,
|
||||
reason: `unknown_sender_${mg.unknown_sender_policy}`,
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: agentGroupId,
|
||||
@@ -104,13 +113,27 @@ function handleUnknownSender(
|
||||
}
|
||||
|
||||
if (mg.unknown_sender_policy === 'request_approval') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', {
|
||||
log.info('MESSAGE DROPPED — unknown sender (approval requested)', {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
// Fire-and-forget; pick-approver + delivery + row-insert are all async.
|
||||
// If it fails it logs internally — the user's message still stays dropped
|
||||
// either way. Requires a resolved userId (senderResolver populates users
|
||||
// row before the gate fires); if we got here without one, there's nothing
|
||||
// to identify for approval and we just stay in the "silent strict" branch.
|
||||
if (userId) {
|
||||
requestSenderApproval({
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
senderIdentity: userId,
|
||||
senderName,
|
||||
event,
|
||||
}).catch((err) => log.error('Sender-approval flow threw', { err }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,3 +179,63 @@ setSenderScopeGate(
|
||||
return { allowed: false, reason: `sender_scope_${decision.reason}` };
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Response handler for the unknown-sender approval card.
|
||||
*
|
||||
* Claim rule: questionId matches a row in pending_sender_approvals. If no
|
||||
* such row, return false so the next handler (approvals module, OneCLI,
|
||||
* interactive) gets a shot.
|
||||
*
|
||||
* Approve: add the sender to agent_group_members + re-invoke routeInbound
|
||||
* with the stored event. The second routing attempt clears the gate because
|
||||
* the user is now a member.
|
||||
*
|
||||
* Deny: delete the row (no "deny list" — a future message re-triggers a
|
||||
* fresh card per ACTION-ITEMS item 5 "no denial persistence").
|
||||
*/
|
||||
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const row = getPendingSenderApproval(payload.questionId);
|
||||
if (!row) return false;
|
||||
|
||||
const approverId = payload.userId ?? row.approver_user_id;
|
||||
const approved = payload.value === 'approve';
|
||||
|
||||
if (approved) {
|
||||
addMember({
|
||||
user_id: row.sender_identity,
|
||||
agent_group_id: row.agent_group_id,
|
||||
added_by: approverId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Unknown sender approved — member added', {
|
||||
approvalId: row.id,
|
||||
senderIdentity: row.sender_identity,
|
||||
agentGroupId: row.agent_group_id,
|
||||
approverId,
|
||||
});
|
||||
|
||||
// Clear the pending row BEFORE re-routing so the gate check on the
|
||||
// second attempt doesn't see the in-flight row and short-circuit.
|
||||
deletePendingSenderApproval(row.id);
|
||||
|
||||
try {
|
||||
const event = JSON.parse(row.original_message) as InboundEvent;
|
||||
await routeInbound(event);
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after sender approval', { approvalId: row.id, err });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
log.info('Unknown sender denied', {
|
||||
approvalId: row.id,
|
||||
senderIdentity: row.sender_identity,
|
||||
agentGroupId: row.agent_group_id,
|
||||
approverId,
|
||||
});
|
||||
deletePendingSenderApproval(row.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleSenderApprovalResponse);
|
||||
|
||||
Reference in New Issue
Block a user