refactor(permissions): preserve pre-PR behavior in three spots
PR #5 review flagged three behavior changes that shouldn't have slipped in. This commit reverts each to match the pre-refactor behavior exactly. 1. User upsert ordering. Split the router hook into two setters: setSenderResolver (runs before agent resolution) and setAccessGate (runs after). Restores the pre-PR sequence where the users row is upserted even if the message is dropped by wiring or trigger rules. 2. dropped_messages audit. Moved src/modules/permissions/db/dropped-messages.ts back to src/db/dropped-messages.ts. The table is core audit infra, not permissions-specific. Router re-writes rows for no_agent_wired and no_trigger_match; the access gate writes rows for policy refusals. 3. Permissionless container fallback. Dropped. poll-loop restores the original deny-all check when NANOCLAW_ADMIN_USER_IDS is empty. Module contract doc updated with the two-hook shape. Validation: host build clean, 137/137 host tests, 17/17 container tests, typecheck clean, service boots to "NanoClaw running" with permissions module registering both hooks and clean SIGTERM shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
export interface UnregisteredSender {
|
||||
channel_type: string;
|
||||
platform_id: string;
|
||||
user_id: string | null;
|
||||
sender_name: string | null;
|
||||
reason: string;
|
||||
messaging_group_id: string | null;
|
||||
agent_group_id: string | null;
|
||||
message_count: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export function recordDroppedMessage(msg: {
|
||||
channel_type: string;
|
||||
platform_id: string;
|
||||
user_id: string | null;
|
||||
sender_name: string | null;
|
||||
reason: string;
|
||||
messaging_group_id: string | null;
|
||||
agent_group_id: string | null;
|
||||
}): void {
|
||||
const now = new Date().toISOString();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO unregistered_senders (channel_type, platform_id, user_id, sender_name, reason, messaging_group_id, agent_group_id, message_count, first_seen, last_seen)
|
||||
VALUES (@channel_type, @platform_id, @user_id, @sender_name, @reason, @messaging_group_id, @agent_group_id, 1, @now, @now)
|
||||
ON CONFLICT (channel_type, platform_id) DO UPDATE SET
|
||||
user_id = COALESCE(excluded.user_id, unregistered_senders.user_id),
|
||||
sender_name = COALESCE(excluded.sender_name, unregistered_senders.sender_name),
|
||||
reason = excluded.reason,
|
||||
message_count = unregistered_senders.message_count + 1,
|
||||
last_seen = excluded.last_seen`,
|
||||
)
|
||||
.run({ ...msg, now });
|
||||
}
|
||||
|
||||
export function getUnregisteredSenders(limit = 50): UnregisteredSender[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM unregistered_senders ORDER BY last_seen DESC LIMIT ?')
|
||||
.all(limit) as UnregisteredSender[];
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
/**
|
||||
* Permissions module — sender resolution + access gate.
|
||||
*
|
||||
* Registers a single inbound-gate via setInboundGate(). The gate owns:
|
||||
* 1. Sender resolution: parse the channel adapter's payload, derive a
|
||||
* namespaced user id, and upsert the `users` row on first sight so
|
||||
* role/access lookups land on a real record thereafter.
|
||||
* 2. Access decision: owners → global admins → scoped admins → members.
|
||||
* 3. Unknown-sender policy: strict drops; request_approval is a TODO
|
||||
* (pending the `add_group_member` action kind).
|
||||
* 4. Audit trail: drops get logged into `dropped_messages`.
|
||||
* Registers two hooks into the core router:
|
||||
* 1. setSenderResolver — runs before agent resolution. Parses the payload,
|
||||
* derives a namespaced user id, and upserts the `users` row on first
|
||||
* sight. Returns null when the payload doesn't carry enough to identify
|
||||
* a sender.
|
||||
* 2. setAccessGate — runs after agent resolution. Enforces the
|
||||
* unknown_sender_policy (strict/request_approval/public) and the
|
||||
* owner/global-admin/scoped-admin/member access hierarchy. Records its
|
||||
* own `dropped_messages` row on refusal (structural drops are recorded
|
||||
* by core).
|
||||
*
|
||||
* Without this module: core's router defaults to allow-all (PR #2), every
|
||||
* message routes through, and no users table is needed. Drops are not
|
||||
* recorded anywhere. Admin commands inside the container fall back to
|
||||
* permissionless mode (see formatter.ts).
|
||||
* Without this module: sender resolution is a no-op (userId=null); the
|
||||
* access gate is not registered and core defaults to allow-all.
|
||||
*/
|
||||
import { setInboundGate, type InboundEvent, type InboundGateResult } from '../../router.js';
|
||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
||||
import { setAccessGate, setSenderResolver, type AccessGateResult, type InboundEvent } from '../../router.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup } from '../../types.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import { getUser, upsertUser } from './db/users.js';
|
||||
|
||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||
@@ -111,24 +111,24 @@ function handleUnknownSender(
|
||||
// 'public' should have been handled before the gate; fall through silently.
|
||||
}
|
||||
|
||||
setInboundGate((event, mg, agentGroupId): InboundGateResult => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
setSenderResolver(extractAndUpsertUser);
|
||||
|
||||
setAccessGate((event, userId, mg, agentGroupId): AccessGateResult => {
|
||||
// Public channels skip the access check entirely.
|
||||
if (mg.unknown_sender_policy === 'public') {
|
||||
return { allowed: true, userId };
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
handleUnknownSender(mg, null, agentGroupId, 'unknown_user', event);
|
||||
return { allowed: false, userId: null, reason: 'unknown_user' };
|
||||
return { allowed: false, reason: 'unknown_user' };
|
||||
}
|
||||
|
||||
const decision = canAccessAgentGroup(userId, agentGroupId);
|
||||
if (decision.allowed) {
|
||||
return { allowed: true, userId };
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
handleUnknownSender(mg, userId, agentGroupId, decision.reason, event);
|
||||
return { allowed: false, userId, reason: decision.reason };
|
||||
return { allowed: false, reason: decision.reason };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user