/** * D12's reachability policy: which remote targets a module may reach with the * FLEET credential (openspec/changes/hook-process-boundary, stage 3). * * The rule: a module may reach the systems it provisioned — its own and its * instances' (`ownedSystemModuleIds`, one indexed query because ownership is * one level deep) — and reaches everything else through capabilities. The * mount set is derived from the hook context; the reachable set is derived * from the same identity. * * Two carve-outs, both following from "scoped by the CREDENTIAL": * * - A request carrying the module's OWN credential (an identity key, or * `installAuthorizedKey`'s password) is not riding the fleet key at all. * The credential is the scope: it works exactly where its holder was * given access, which celilo neither grants nor needs to police. * - An explicit NON-ROOT user. The fleet key's authority is root on the * systems celilo provisions — that is the only place celilo installs it. * A non-root account trusts it only where an operator deliberately put it * there (the cPanel bootstrap installs it into an unprivileged hosting * account), and refusing those would break every off-fleet provider while * protecting nothing the key actually unlocks. The residual — an operator * who installed the fleet key as root on an OFF-fleet box — is refused * until that target carries its own identity, and design D12 records it. * * **Attribution (D12, task 5.1b).** A policy is built for the module that * PERFORMS the operation, never the one that asked for it. For a hook that is * the hook's module — the nine `invokeHook` call sites construct it with the * id they are invoking. A capability provider's internal transport belongs to * the provider's module; providers run in celilo's own process today (design * D10), so their transport does not cross the remote-ops broker, and the one * hand-built-ssh provider site (`public_web`'s upload) is being replaced by * an Ansible converge under `openspec/changes/capability-owned-tables` * (celilo#1014). If provider transport is ever brokered, it takes a policy * built with the PROVIDER's id from this same function — neither "the calling * module's systems" nor an exemption. * * Policy function (Rule 10.1) — decides; the remote-ops broker enforces. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { capabilities, machines, moduleConfigs, moduleSystems } from '../db/schema'; import type { RemoteAccessPolicy } from '../hooks/remote-broker'; import { getModuleSystems } from './deployed-systems'; import { ownedSystemModuleIds } from './module-instances'; /** Build the policy one hook run's remote operations are checked against. */ export function remoteAccessPolicy(moduleId: string, db: DbClient): RemoteAccessPolicy { return { moduleId, checkTarget(target, hasOwnCredential) { if (hasOwnCredential) return { allowed: true }; if (target.user !== undefined && target.user !== 'root') return { allowed: true }; // Queried per call rather than snapshotted at invoke time, so a system // recorded mid-run (a provider arrival) is visible to the next check. const owned = ownedSystemModuleIds(moduleId, db).flatMap((id) => getModuleSystems(id, db)); if (owned.some((system) => system.ipv4_address === target.ipv4_address)) { return { allowed: true }; } if (managesPoolMachine(moduleId, target.ipv4_address, db)) { return { allowed: true }; } return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) }; }, }; } /** * A machine in celilo's pool that this module's own config names. * * ## The case this exists for * * A module that MANAGES a pre-existing box rather than being deployed onto one * — `iptables` configuring the firewall is the archetype — provisions no system * and therefore never owns one. `module_systems` records nothing for a * config-only module at any point in its lifecycle, so the owned-systems branch * above cannot ever pass for it. Without this, such a module has no sanctioned * path to the one box it exists to configure, and no amount of waiting fixes it * (celilo#1225). * * ## Why this is not a module granting itself reach * * Both halves are the OPERATOR's acts, and a module can supply neither: * * - The address is in the machine pool. `celilo machine add --ssh-key…` * is the operator saying celilo may use its credential on that box. The * fleet key's authority is exactly the set of boxes celilo was given, and * this is that set — the same reasoning as the non-root carve-out above. * - The module's own config names that address. `firewall_ip` is * `source: user, required: true`; an operator typed it. * * A module declares which config KEYS exist, never their values, so it cannot * point itself at a box the operator did not both admit to the pool and assign * to it. Two independent operator decisions have to agree. * * ## What it deliberately does not do * * It does not allow every pool machine. That would let any module reach any box * celilo knows, which is far wider than the rule this file states. The * intersection is the point: pool membership alone is not authorization, and * config alone is not either. */ function managesPoolMachine(moduleId: string, ipv4Address: string, db: DbClient): boolean { const inPool = db.select().from(machines).where(eq(machines.ipAddress, ipv4Address)).get(); if (!inPool) return false; return db .select({ value: moduleConfigs.value }) .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all() .some((row) => row.value === ipv4Address); } /** * Name the module, the target, and the capability route (task 5.2). The owner * lookup is best-effort colour: the refusal stands whether or not celilo can * say whose system it is. */ function refusalMessage(moduleId: string, ipv4Address: string, db: DbClient): string { const preamble = `Module '${moduleId}' may not reach ${ipv4Address}: it is not a system this module (or one of its instances) provisioned.`; const guidance = 'A hook reaches its own systems via ctx.systems and everything else through a capability (hook-process-boundary, design D12).'; const owner = db .select() .from(moduleSystems) .where(eq(moduleSystems.ipv4Address, ipv4Address)) .get(); if (!owner) return `${preamble} ${guidance}`; const provided = db .select({ capabilityName: capabilities.capabilityName }) .from(capabilities) .where(eq(capabilities.moduleId, owner.moduleId)) .all() .map((row) => row.capabilityName); const route = provided.length > 0 ? `Reach it through a capability it provides ('${provided.join("', '")}').` : "Reach it through a capability, never by SSH from another module's hook."; return `${preamble} That system ('${owner.hostname}') belongs to module '${owner.moduleId}'. ${route}`; }