refactor(modules): extract permissions as optional module

Moves user-roles / users / agent-group-members / user-dms /
dropped-messages / user-dm / canAccessAgentGroup into
src/modules/permissions/. Module registers a single inbound-gate that
owns sender resolution, access decision, unknown-sender policy, and
drop-audit recording.

Router slimmed from 357 → 179 lines; the inline fallback chain
(extractAndUpsertUser / enforceAccess / handleUnknownSender /
recordDroppedMessage) is gone — without the permissions module core
defaults to allow-all with userId=null.

container-runner's admin-ID query is now inline SQL guarded by
sqlite_master on user_roles, keeping core free of any import from the
permissions module. The container-side formatter falls back to
permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender
with an identifiable senderId is treated as admin.

Module contract doc formalizes the tier model and the dependency rule
(core ← default modules ← optional modules). One transitional violation
flagged: src/access.ts (core) imports from the permissions module for
its remaining approver-picking helpers; resolves in the planned PR #7
re-tier.

Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering its gate 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 17:42:14 +03:00
parent e75af5e44d
commit 7cc4ecc3be
16 changed files with 279 additions and 304 deletions

View File

@@ -16,4 +16,5 @@
import './interactive/index.js';
import './approvals/index.js';
import './scheduling/index.js';
import './permissions/index.js';

View File

@@ -0,0 +1,29 @@
/**
* Access control (permissions module half of src/access.ts).
*
* Privilege is user-level, not group-level. A user holds zero or more roles
* (owner | admin) via `user_roles`, and is optionally "known" in specific
* agent groups via `agent_group_members`. Admins are implicitly members of
* the groups they administer.
*
* The approver-picking functions (pickApprover, pickApprovalDelivery) stay
* in src/access.ts for now — they move into the approvals module in the
* planned PR #7 re-tier.
*/
import { isMember } from './db/agent-group-members.js';
import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './db/user-roles.js';
import { getUser } from './db/users.js';
export type AccessDecision =
| { allowed: true; reason: 'owner' | 'global_admin' | 'admin_of_group' | 'member' }
| { allowed: false; reason: 'unknown_user' | 'not_member' };
/** Can this user interact with this agent group? */
export function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision {
if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' };
if (isOwner(userId)) return { allowed: true, reason: 'owner' };
if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };
if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };
if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };
return { allowed: false, reason: 'not_member' };
}

View File

@@ -0,0 +1,44 @@
import type { AgentGroupMember } from '../../../types.js';
import { getDb } from '../../../db/connection.js';
import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './user-roles.js';
export function addMember(row: AgentGroupMember): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES (@user_id, @agent_group_id, @added_by, @added_at)`,
)
.run(row);
}
export function removeMember(userId: string, agentGroupId: string): void {
getDb().prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?').run(userId, agentGroupId);
}
export function getMembers(agentGroupId: string): AgentGroupMember[] {
return getDb()
.prepare('SELECT * FROM agent_group_members WHERE agent_group_id = ? ORDER BY added_at')
.all(agentGroupId) as AgentGroupMember[];
}
/**
* Is the user "known" in this agent group?
* Owner, global admin, and scoped admin are implicitly members.
*/
export function isMember(userId: string, agentGroupId: string): boolean {
if (isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId)) {
return true;
}
const row = getDb()
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
.get(userId, agentGroupId);
return !!row;
}
/** Direct row lookup — does not honor the admin/owner implicit-membership rule. */
export function hasMembershipRow(userId: string, agentGroupId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
.get(userId, agentGroupId);
return !!row;
}

View File

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,28 @@
import type { UserDm } from '../../../types.js';
import { getDb } from '../../../db/connection.js';
export function upsertUserDm(row: UserDm): void {
getDb()
.prepare(
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
VALUES (@user_id, @channel_type, @messaging_group_id, @resolved_at)
ON CONFLICT(user_id, channel_type) DO UPDATE SET
messaging_group_id = excluded.messaging_group_id,
resolved_at = excluded.resolved_at`,
)
.run(row);
}
export function getUserDm(userId: string, channelType: string): UserDm | undefined {
return getDb().prepare('SELECT * FROM user_dms WHERE user_id = ? AND channel_type = ?').get(userId, channelType) as
| UserDm
| undefined;
}
export function getUserDmsForUser(userId: string): UserDm[] {
return getDb().prepare('SELECT * FROM user_dms WHERE user_id = ?').all(userId) as UserDm[];
}
export function deleteUserDm(userId: string, channelType: string): void {
getDb().prepare('DELETE FROM user_dms WHERE user_id = ? AND channel_type = ?').run(userId, channelType);
}

View File

@@ -0,0 +1,85 @@
import type { UserRole, UserRoleKind } from '../../../types.js';
import { getDb } from '../../../db/connection.js';
/**
* Grant a role. Owner rows must have agent_group_id = null (enforced here,
* not by schema, so callers get a clean error path).
*/
export function grantRole(row: UserRole): void {
if (row.role === 'owner' && row.agent_group_id !== null) {
throw new Error('owner role must be global (agent_group_id = null)');
}
getDb()
.prepare(
`INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES (@user_id, @role, @agent_group_id, @granted_by, @granted_at)`,
)
.run(row);
}
export function revokeRole(userId: string, role: UserRoleKind, agentGroupId: string | null): void {
if (agentGroupId === null) {
getDb()
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL')
.run(userId, role);
} else {
getDb()
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ?')
.run(userId, role, agentGroupId);
}
}
export function getUserRoles(userId: string): UserRole[] {
return getDb().prepare('SELECT * FROM user_roles WHERE user_id = ?').all(userId) as UserRole[];
}
export function isOwner(userId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
.get(userId, 'owner');
return !!row;
}
export function isGlobalAdmin(userId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
.get(userId, 'admin');
return !!row;
}
export function isAdminOfAgentGroup(userId: string, agentGroupId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ? LIMIT 1')
.get(userId, 'admin', agentGroupId);
return !!row;
}
/** Any admin privilege over this agent group: global admin OR scoped admin. */
export function hasAdminPrivilege(userId: string, agentGroupId: string): boolean {
return isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId);
}
export function getOwners(): UserRole[] {
return getDb()
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
.all('owner') as UserRole[];
}
export function hasAnyOwner(): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE role = ? AND agent_group_id IS NULL LIMIT 1')
.get('owner');
return !!row;
}
export function getGlobalAdmins(): UserRole[] {
return getDb()
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
.all('admin') as UserRole[];
}
export function getAdminsOfAgentGroup(agentGroupId: string): UserRole[] {
return getDb()
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id = ? ORDER BY granted_at')
.all('admin', agentGroupId) as UserRole[];
}

View File

@@ -0,0 +1,38 @@
import type { User } from '../../../types.js';
import { getDb } from '../../../db/connection.js';
export function createUser(user: User): void {
getDb()
.prepare(
`INSERT INTO users (id, kind, display_name, created_at)
VALUES (@id, @kind, @display_name, @created_at)`,
)
.run(user);
}
export function upsertUser(user: User): void {
getDb()
.prepare(
`INSERT INTO users (id, kind, display_name, created_at)
VALUES (@id, @kind, @display_name, @created_at)
ON CONFLICT(id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, users.display_name)`,
)
.run(user);
}
export function getUser(id: string): User | undefined {
return getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
}
export function getAllUsers(): User[] {
return getDb().prepare('SELECT * FROM users ORDER BY created_at').all() as User[];
}
export function updateDisplayName(id: string, displayName: string): void {
getDb().prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, id);
}
export function deleteUser(id: string): void {
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
}

View File

@@ -0,0 +1,134 @@
/**
* 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`.
*
* 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).
*/
import { setInboundGate, type InboundEvent, type InboundGateResult } 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 {
let content: Record<string, unknown>;
try {
content = JSON.parse(event.message.content) as Record<string, unknown>;
} catch {
return null;
}
// chat-sdk-bridge serializes author info as a nested `author.userId` and
// does NOT populate top-level `senderId`. Older adapters (v1, native) put
// `senderId` or `sender` directly at the top level. Check all three.
const senderIdField = typeof content.senderId === 'string' ? content.senderId : undefined;
const senderField = typeof content.sender === 'string' ? content.sender : undefined;
const author =
typeof content.author === 'object' && content.author !== null
? (content.author as Record<string, unknown>)
: undefined;
const authorUserId = typeof author?.userId === 'string' ? (author.userId as string) : undefined;
const senderName =
(typeof content.senderName === 'string' ? content.senderName : undefined) ??
(typeof author?.fullName === 'string' ? (author.fullName as string) : undefined) ??
(typeof author?.userName === 'string' ? (author.userName as string) : undefined);
const rawHandle = senderIdField ?? senderField ?? authorUserId;
if (!rawHandle) return null;
const userId = rawHandle.includes(':') ? rawHandle : `${event.channelType}:${rawHandle}`;
if (!getUser(userId)) {
upsertUser({
id: userId,
kind: event.channelType,
display_name: senderName ?? null,
created_at: new Date().toISOString(),
});
}
return userId;
}
function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } {
try {
return JSON.parse(raw);
} catch {
return { text: raw };
}
}
function handleUnknownSender(
mg: MessagingGroup,
userId: string | null,
agentGroupId: string,
accessReason: string,
event: InboundEvent,
): void {
const parsed = safeParseContent(event.message.content);
const dropRecord = {
channel_type: event.channelType,
platform_id: event.platformId,
user_id: userId,
sender_name: parsed.sender ?? null,
reason: `unknown_sender_${mg.unknown_sender_policy}`,
messaging_group_id: mg.id,
agent_group_id: agentGroupId,
};
if (mg.unknown_sender_policy === 'strict') {
log.info('MESSAGE DROPPED — unknown sender (strict policy)', {
messagingGroupId: mg.id,
agentGroupId,
userId,
accessReason,
});
recordDroppedMessage(dropRecord);
return;
}
if (mg.unknown_sender_policy === 'request_approval') {
log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', {
messagingGroupId: mg.id,
agentGroupId,
userId,
accessReason,
});
recordDroppedMessage(dropRecord);
return;
}
// 'public' should have been handled before the gate; fall through silently.
}
setInboundGate((event, mg, agentGroupId): InboundGateResult => {
const userId = extractAndUpsertUser(event);
// Public channels skip the access check entirely.
if (mg.unknown_sender_policy === 'public') {
return { allowed: true, userId };
}
if (!userId) {
handleUnknownSender(mg, null, agentGroupId, 'unknown_user', event);
return { allowed: false, userId: null, reason: 'unknown_user' };
}
const decision = canAccessAgentGroup(userId, agentGroupId);
if (decision.allowed) {
return { allowed: true, userId };
}
handleUnknownSender(mg, userId, agentGroupId, decision.reason, event);
return { allowed: false, userId, reason: decision.reason };
});

View File

@@ -0,0 +1,146 @@
/**
* User DM resolution.
*
* Exposes one primitive: `ensureUserDm(userId)` returns (or lazily creates)
* the `messaging_groups` row that the host should deliver to when it wants
* to DM a given user. Everything that needs to cold-DM a user — approvals,
* pairing handshakes, host notifications — goes through this function.
*
* ## Two-class resolution
*
* Channels split cleanly into two classes based on whether the user id is
* already the DM platform id:
*
* - **Direct-addressable** (Telegram, WhatsApp, iMessage, email, Matrix):
* user handle IS the DM chat id. No adapter method needed; we just
* mint a messaging_group row with `platform_id = handle`.
*
* - **Resolution-required** (Discord, Slack, Teams, Webex, gChat):
* user id and DM channel id are different. The adapter must implement
* `openDM(handle)`, which Chat SDK's `chat.openDM` handles for us via
* the bridge. The returned channel id becomes the `platform_id`.
*
* ## Caching
*
* Successful resolutions are persisted in `user_dms (user_id, channel_type
* → messaging_group_id)`. The cache survives restarts; first-time DMs on a
* given channel pay one `openDM` round trip, everyone after is a pure DB
* read.
*
* The underlying platform APIs (`POST /users/@me/channels` on Discord,
* `conversations.open` on Slack, etc.) are idempotent and return the same
* channel on repeated calls, so re-resolving after a cache miss is always
* safe — worst case we round-trip redundantly.
*/
import { getChannelAdapter } from '../../channels/channel-registry.js';
import { getMessagingGroup, getMessagingGroupByPlatform, createMessagingGroup } from '../../db/messaging-groups.js';
import { log } from '../../log.js';
import type { MessagingGroup, User } from '../../types.js';
import { getUser } from './db/users.js';
import { getUserDm, upsertUserDm } from './db/user-dms.js';
/**
* Return a messaging_group usable to DM this user, creating it lazily if
* needed. Returns null when:
* - the user id isn't namespaced (no `kind:handle` prefix)
* - the user's channel has no adapter registered
* - the channel needs openDM but its adapter doesn't implement it
* - openDM throws (platform error, user blocked bot, etc.)
*
* Callers should treat null as "this user is unreachable on this channel".
*/
export async function ensureUserDm(userId: string): Promise<MessagingGroup | null> {
const user = getUser(userId);
if (!user) {
log.warn('ensureUserDm: user not found', { userId });
return null;
}
const { channelType, handle } = parseUserId(user);
if (!channelType || !handle) {
log.warn('ensureUserDm: user id not namespaced', { userId });
return null;
}
// Cache hit: existing user_dms row → load and return the messaging_group.
const cached = getUserDm(userId, channelType);
if (cached) {
const mg = getMessagingGroup(cached.messaging_group_id);
if (mg) return mg;
// Row points to a deleted messaging_group — fall through and re-resolve.
log.warn('ensureUserDm: cached row references missing messaging_group, re-resolving', {
userId,
messagingGroupId: cached.messaging_group_id,
});
}
// Cache miss: resolve the DM platform_id either via openDM or directly.
const dmPlatformId = await resolveDmPlatformId(channelType, handle);
if (!dmPlatformId) return null;
// Find-or-create the underlying messaging_group. A DM we received
// earlier may already have a row matching (channel_type, platform_id).
const now = new Date().toISOString();
let mg = getMessagingGroupByPlatform(channelType, dmPlatformId);
if (!mg) {
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
channel_type: channelType,
platform_id: dmPlatformId,
name: user.display_name,
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now,
};
createMessagingGroup(mg);
log.info('ensureUserDm: created DM messaging_group', {
userId,
channelType,
messagingGroupId: mgId,
});
}
upsertUserDm({
user_id: userId,
channel_type: channelType,
messaging_group_id: mg.id,
resolved_at: now,
});
return mg;
}
/**
* Call the adapter's openDM if it has one; otherwise fall through to using
* the handle directly. Returns null if the adapter is missing entirely.
*/
async function resolveDmPlatformId(channelType: string, handle: string): Promise<string | null> {
const adapter = getChannelAdapter(channelType);
if (!adapter) {
log.warn('ensureUserDm: no adapter for channel', { channelType });
return null;
}
if (!adapter.openDM) {
// Direct-addressable channel — handle doubles as the DM chat id.
return handle;
}
try {
return await adapter.openDM(handle);
} catch (err) {
log.error('ensureUserDm: adapter.openDM failed', { channelType, handle, err });
return null;
}
}
function parseUserId(user: User): { channelType: string; handle: string } | { channelType: null; handle: null } {
const idx = user.id.indexOf(':');
if (idx < 0) return { channelType: null, handle: null };
const channelType = user.id.slice(0, idx);
const handle = user.id.slice(idx + 1);
if (!channelType || !handle) return { channelType: null, handle: null };
// The `kind` on users mirrors the channel_type prefix in our current
// scheme. Pull it from `user.kind` if we ever decouple them later, but
// today the id prefix is authoritative.
return { channelType, handle };
}