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,56 +1,30 @@
/**
* Access control + approval routing.
* Approval routing helpers (temporary home).
*
* 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.
* These functions pick an approver for a sensitive action and resolve the
* DM messaging_group they should be delivered to. They're called only from
* the approvals module.
*
* Sensitive actions trigger an approval flow, routed to the admin of the
* originating agent group; if none, the owner. Approval delivery lands in
* the approver's DM on (ideally) the same channel kind as the originating
* request. DM resolution (including cold DMs) is handled by ensureUserDm.
* PR #5 moved the access-decision half of this file (canAccessAgentGroup +
* AccessDecision) into src/modules/permissions/. The approver-picking half
* stays here as a temporary shim — PR #7 relocates it into a new default
* approvals-primitive module alongside the approvals re-tier.
*
* Tier note: this file lives in core but imports from the permissions
* optional module. That's a deliberate temporary violation; see the module
* contract + REFACTOR_PLAN open question #3.
*/
import { getAgentGroup } from './db/agent-groups.js';
import { isMember } from './db/agent-group-members.js';
import {
getAdminsOfAgentGroup,
getGlobalAdmins,
getOwners,
hasAdminPrivilege,
isAdminOfAgentGroup,
isGlobalAdmin,
isOwner,
} from './db/user-roles.js';
import { getUser } from './db/users.js';
import { ensureUserDm } from './user-dm.js';
} from './modules/permissions/db/user-roles.js';
import { ensureUserDm } from './modules/permissions/user-dm.js';
import type { MessagingGroup } from './types.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' };
}
/** Can this user perform privileged (admin) operations on this agent group? */
export function canAdminAgentGroup(userId: string, agentGroupId: string): boolean {
return hasAdminPrivilege(userId, agentGroupId);
}
/**
* Ordered list of user IDs eligible to approve an action for the given agent
* group. Preference: admins @ that group → global admins → owners.
*
* The approver-picking policy is to try local admins first (they have direct
* context for the group), then fall back to global scope.
*/
export function pickApprover(agentGroupId: string | null): string[] {
const approvers: string[] = [];
@@ -100,15 +74,6 @@ export async function pickApprovalDelivery(
return null;
}
/**
* Resolve the agent group id for a session's originating request. Used by
* approval routing so we know which scope to pick admins from.
*/
export function agentGroupIdForSession(sessionAgentGroupId: string | null): string | null {
if (!sessionAgentGroupId) return null;
return getAgentGroup(sessionAgentGroupId)?.id ?? null;
}
function channelTypeOf(userId: string): string {
const idx = userId.indexOf(':');
return idx < 0 ? '' : userId.slice(0, idx);