*/ private array $rules = []; /** @param callable(Principal, ?Resource): Decision $rule */ public function define(string $operation, callable $rule): void { $this->rules[$operation] = $rule; } public function check(Principal $principal, string $operation, ?Resource $resource = null): Decision { $rule = $this->rules[$operation] ?? null; if ($rule === null) { return Decision::forbidden(); } if ($resource !== null && $resource->siteId !== $principal->siteId) { return Decision::notFound(); } return $rule($principal, $resource); } public function allows(Principal $principal, string $operation, ?Resource $resource = null): bool { return $this->check($principal, $operation, $resource)->allowed; } /** Site-level operation gated by one capability. */ public static function capability(string $capability): callable { return static function (Principal $p, ?Resource $r) use ($capability): Decision { if (!$p->isAuthenticated()) { return Decision::unauthenticated(); } return $p->can($capability) ? Decision::allow() : Decision::forbidden(); }; } /** Object owned by a chat session (and by its user, when the session belongs to one). */ public static function sessionOwner(): callable { return static function (Principal $p, ?Resource $r): Decision { if ($r === null || $p->chatSessionId === null || $r->ownerSessionId !== $p->chatSessionId) { return Decision::notFound(); } if ($r->ownerUserId !== null && $r->ownerUserId !== $p->userId) { return Decision::notFound(); } return Decision::allow(); }; } public static function withDefaults(): self { $policy = new self(); // index.manage: embedding budget, build start/pause/resume/cancel, unblocking; index.activate: switching what answers use; // bots.delete (phase 111): deleting an assistant and, if chosen, its conversations, like deleting a conversation. // All are site administrator operations (the capability is per site on a network). foreach (['settings.manage', 'connections.manage', 'modules.manage', 'index.manage', 'index.activate', 'bots.delete'] as $op) { $policy->define($op, self::capability(Capabilities::MANAGE)); } foreach (['usage.view', 'jobs.view', 'diagnostics.view'] as $op) { $policy->define($op, self::capability(Capabilities::VIEW_USAGE)); } // Stage E4: every MCP tool call needs this on top of the administrator's per-tool exposure list; a call is never more than a read. $policy->define('mcp.read', self::capability(Capabilities::USE_MCP)); $policy->define('bots.manage', self::capability(Capabilities::MANAGE_BOTS)); $policy->define('knowledge.manage', self::capability(Capabilities::MANAGE_KNOWLEDGE)); // Phase 96: only this operation may turn a private source public; editors can add and make private, never publish. $policy->define('knowledge.publish', self::capability(Capabilities::PUBLISH_KNOWLEDGE)); $policy->define('bots.preview', self::capability(Capabilities::MANAGE_BOTS)); // Anyone may open a session on an active public bot; the handler still applies quotas and budget. $policy->define('chat.session.create', static function (Principal $p, ?Resource $bot): Decision { if ($bot === null || $bot->type !== 'bot') { return Decision::notFound(); } return $bot->attr('status') === 'active' && $bot->attr('is_public') === true ? Decision::allow() : Decision::notFound(); }); $owner = self::sessionOwner(); $sessionOwned = static function (Principal $p, ?Resource $r) use ($owner): Decision { if ($r === null || $r->type !== 'chat_session') { return Decision::notFound(); } return $owner($p, $r); }; foreach (['chat.message.send', 'chat.history.read', 'chat.cancel', 'chat.session.delete'] as $op) { $policy->define($op, $sessionOwned); } $policy->define('conversations.export', static function (Principal $p, ?Resource $r) use ($sessionOwned): Decision { if ($r !== null && $r->type === 'chat_session' && $p->isAuthenticated() && $p->can(Capabilities::EXPORT_CONVERSATIONS)) { return Decision::allow(); } return $sessionOwned($p, $r); }); return $policy; } }