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:
gavrielc
2026-04-18 18:00:10 +03:00
parent 7cc4ecc3be
commit 32bcc2c5ae
5 changed files with 142 additions and 75 deletions

View File

@@ -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[];
}

View File

@@ -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 };
});