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

@@ -1,44 +0,0 @@
import type { AgentGroupMember } from '../types.js';
import { getDb } from './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

@@ -1,44 +0,0 @@
import { getDb } from './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

@@ -8,22 +8,6 @@ export {
updateAgentGroup,
deleteAgentGroup,
} from './agent-groups.js';
export { createUser, upsertUser, getUser, getAllUsers, updateDisplayName, deleteUser } from './users.js';
export {
grantRole,
revokeRole,
getUserRoles,
isOwner,
isGlobalAdmin,
isAdminOfAgentGroup,
hasAdminPrivilege,
getOwners,
hasAnyOwner,
getGlobalAdmins,
getAdminsOfAgentGroup,
} from './user-roles.js';
export { addMember, removeMember, getMembers, isMember, hasMembershipRow } from './agent-group-members.js';
export { upsertUserDm, getUserDm, getUserDmsForUser, deleteUserDm } from './user-dms.js';
export {
createMessagingGroup,
getMessagingGroup,

View File

@@ -1,28 +0,0 @@
import type { UserDm } from '../types.js';
import { getDb } from './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

@@ -1,85 +0,0 @@
import type { UserRole, UserRoleKind } from '../types.js';
import { getDb } from './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

@@ -1,38 +0,0 @@
import type { User } from '../types.js';
import { getDb } from './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);
}