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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user