Skip to content

pfSense least-privilege matrix — READ and WRITE

Status: does not authorize provisioning, user creation, or privilege assignment against any pfSense appliance. Companion data to ADR-033, which covers the bootstrap architecture this matrix feeds. See that ADR for the full design; this document is the raw evidence table plus its derivation methodology.

2026-08-20 — outbound-NAT/1:1-NAT mappings live-verified and registered: PfSenseClient.get_firewall_nat_outbound_mappings()/ get_firewall_nat_one_to_one_mappings(), their models, and their Endpoints entries (FIREWALL_NAT_OUTBOUND_MAPPINGS/FIREWALL_NAT_ONE_ TO_ONE_MAPPINGS) were added offline (2026-08-20 earlier this same day, derived from the pinned v2.10 OpenAPI schema) and deliberately held back from tools/registry.py/KNOWN_READ_TOOL_NAMES until live-verified, per tests/test_public_contract.py::test_public_contract_is_complete_ and_security_preserving's requirement that every registered tool's endpoint be verified=True. Owner then authorized a narrowly-scoped live production READ verification of exactly these two endpoints: both GETs succeeded against the production appliance (pfSense Plus 26.07-RELEASE) with zero configured mappings in either category at verification time. Field-level type/nullability compatibility was confirmed via an exact, byte-for-byte match between the live OpenAPI schema's OutboundNATMapping/OneToOneNATMapping components (and both endpoints' full "Allowed privileges" description text) and the pinned v2.10 reference this project's models were already derived from — not by parsing a live instance, since none exist on the target appliance. Both Endpoints entries are now verified=True; both tools are now registered. 41 READ / 42 combined become 43 READ / 44 combined throughout this document as of this pass.

2026-08-20 — interface VLANs / static routes LAB-verified and registered (READ Expansion phase, offline discovery-audit P0 candidates): PfSenseClient.get_interface_vlans()/ get_routing_static_routes(), their models, and their Endpoints entries (INTERFACE_VLANS/ROUTING_STATIC_ROUTES) were added offline earlier the same day, derived from the pinned v2.10 OpenAPI schema, and held back from tools/registry.py until live-verified. Owner then authorized READ-only verification against the LAB appliance (https://pfsense-test.lab.invalid, pfSense CE 2.8.1-RELEASE — a distinct identity from production, confirmed before any request). The LAB's REST API package reported version v2.10 — an exact, byte-for-byte schema match (267/267 paths) against the pinned reference these models were derived from. Both typed GETs succeeded (HTTP 200, correct {"data": [...]} envelope) with zero configured VLANs/static routes on the LAB appliance at verification time — ENDPOINT_VERIFIED, not FIELD_MODEL_LIVE_VERIFIED (no populated object to exercise field parsing); field-type compatibility is backed by the schema-component match above, the same method already established for the NAT mappings. Both Endpoints entries are now verified=True; both tools are now registered. 43 READ / 44 combined become 45 READ / 46 combined throughout this document as of this pass.

2026-08-20 — interface groups / firewall schedules / REST API version LAB-verified and registered (READ Expansion phase, Batch 2 — zero-redaction P0 candidates): PfSenseClient.get_interface_groups()/ get_firewall_schedules()/get_system_restapi_version(), their models, and their Endpoints entries (INTERFACE_GROUPS/ FIREWALL_SCHEDULES/SYSTEM_RESTAPI_VERSION) were implemented and LAB-verified in the same pass as the interface-VLAN/static-route addition above, against the same LAB appliance. interface/groups and firewall/schedules returned zero configured objects (ENDPOINT_VERIFIED only). system/restapi/version returned a fully populated singleton object — FIELD_MODEL_LIVE_VERIFIED — which also confirmed install_version is genuinely absent from the live response (not merely null), so that field is modeled as optional rather than required. All three Endpoints entries are now verified=True; all three tools are now registered. 45 READ / 46 combined become 48 READ / 49 combined throughout this document as of this pass.

2026-08-20 — firewall virtual IPs / certificate authorities LAB-verified and registered (READ Expansion phase, Batch 3 — redaction-bearing P0 candidates): PfSenseClient.get_firewall_virtual_ips()/ get_system_certificate_authorities(), their models, and their Endpoints entries (FIREWALL_VIRTUAL_IPS/ SYSTEM_CERTIFICATE_AUTHORITIES) were implemented and LAB-verified against the same LAB appliance. Both candidates carry a confirmed secret field in the pinned schema (VirtualIP.password, the CARP shared advertisement secret; CertificateAuthority.prv, the CA private key) — both are never modeled at all, mirroring the already-shipped SystemCertificate model's own established treatment of the identical prv distinction, not merely redacted behind a flag. firewall/virtual_ips returned zero configured objects (ENDPOINT_VERIFIED only). system/certificate_authorities returned one real, populated object — the LAB's own internal CA — FIELD_MODEL_LIVE_VERIFIED; the parsed model had no prv attribute at all, proven by construction rather than by inspecting and discarding a captured value. Both Endpoints entries are now verified=True; both tools are now registered. 48 READ / 49 combined become 50 READ / 51 combined throughout this document as of this pass.

2026-08-21 — IPsec SA/child-SA status LAB-verified and registered (P1 Batch A, partial — operational/status value): the LAB appliance was upgraded to pfSense CE 2.9.0-RELEASE (FreeBSD 16.0-CURRENT, pfSense-pkg-RESTAPI v2.10 reinstalled, same 267-path schema) ahead of this batch; a required pre-batch regression check of all 51 existing public tools found and fixed 2 nullability compatibility issues (DhcpServer, DnsResolverSettings — see CHANGELOG.md), unrelated to this batch's own candidates. PfSenseClient.get_status_ipsec_sas()/ get_status_ipsec_child_sas(), their models, and their Endpoints entries (STATUS_IPSEC_SAS/STATUS_IPSEC_CHILD_SAS) were implemented and LAB-verified against the upgraded LAB appliance — both returned zero configured SAs (ENDPOINT_VERIFIED only). IPsecSaStatus.child_sas is schema-confirmed to embed full IPsecChildSaStatus objects and is constructed through that model's own from_api() for every nested item, not passed through as a raw dict. local_host/remote_host/ local_id/remote_id (on IPsecSaStatus) and local_ts/remote_ts (on IPsecChildSaStatus, including within nested child_sas items) are redacted by default. Both Endpoints entries are now verified=True; both tools are now registered. 50 READ / 51 combined become 52 READ / 53 combined throughout this document as of this pass.

The same batch also implemented status/wireguard/tunnels/ status/wireguard/peers (WireGuardTunnelStatus/WireGuardPeerStatus models, offline-tested) but LAB verification was initially blocked: this LAB did not have pfSense-pkg-WireGuard installed (HTTP 404, response_id=MODEL_MISSING_REQUIRED_PACKAGE, observed directly). WireGuardPeerStatus.preshared_key is confirmed present in the schema (in the status object, not merely config) and is never modeled at all, matching the VirtualIP.password/CertificateAuthority.prv precedent — this exclusion is independent of, and unaffected by, the verification blocker. WireGuardTunnelStatus.peers is schema-confirmed to embed full WireGuardPeerStatus objects; it is constructed through that model's own from_api() for every nested item specifically so the preshared_key exclusion holds for the nested case too (a raw-dict passthrough there would have leaked it).

2026-08-21 (same day, later) — WireGuard status LAB-verified and registered after owner-authorized package installation. The owner explicitly authorized installing pfSense-pkg-WireGuard on this LAB for non-production READ verification only. Preflight: reconfirmed LAB identity (pfsense-test.lab.invalid, pfSense CE 2.9.0-RELEASE) is distinct from production; identified the LAB as Proxmox VM 250 ("pfSense-LAB", the only pfSense-named guest in the cluster — no production VM exists there to confuse it with) and took a fresh snapshot (pre-wireguard-ce290) as a rollback point before any change; confirmed pfSense-pkg-WireGuard (version 0.2.13_4) as the correct, available package for this CE 2.9.0 install with no unmet dependencies. Installed via POST /api/v2/system/package (a one-off authenticated LAB administrative call, made outside and independent of this project's own WriteApiClient/WriteEndpoints allow-list mechanism, which remains untouched and still empty except FIREWALL_ALIAS_DESCRIPTION — this was not a WRITE-capability expansion of the shipped server). Post-install: confirmed pfSense/ pfREST healthy, re-ran a 52-tool regression subset (all existing public tools except the local-only pfsense_mcp_info) with zero regressions, then both WireGuard status endpoints succeeded live (HTTP 200, correct envelope, zero configured tunnels/peers — ENDPOINT_VERIFIED); the raw response bodies were inspected directly and contained no unexpected fields. Both Endpoints entries are now verified=True; both tools are now registered. 52 READ / 53 combined become 54 READ / 55 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — OpenVPN status cluster LAB-verified and registered (P1 Batch B). Before implementing, the open question of whether OpenVpnServerStatus's nested conns/routes duplicate the standalone status/openvpn/server/connections/routes endpoints was resolved via the pinned schema's own Parent model declaration: both standalone endpoints declare Parent model: OpenVPNServerStatus, the identical structural relationship already established as non-redundant between IPsecSaStatus/IPsecChildSaStatus in Batch A — implemented as four genuinely independent, non-duplicative endpoints on that basis (live OpenVPN data was unavailable on this LAB either way to settle it empirically). PfSenseClient. get_status_openvpn_servers()/get_status_openvpn_clients()/ get_status_openvpn_server_connections()/get_status_openvpn_server_routes(), their models, and their Endpoints entries were implemented and LAB-verified — all four returned zero configured objects (ENDPOINT_VERIFIED only; no package required, a base pfSense feature). common_name/remote_host/user_name/virtual_addr/ virtual_addr6 (real per-connection human/device identity data, not merely topology, on OpenVpnServerConnectionStatus) and the analogous common_name/remote_host/virtual_addr (on OpenVpnServerRouteStatus) and local_host/remote_host/virtual_addr/virtual_addr6 (on OpenVpnClientStatus) are redacted by default. OpenVpnServerStatus.conns/ .routes are schema-confirmed to embed full OpenVpnServerConnectionStatus/ OpenVpnServerRouteStatus objects and are constructed through those models' own from_api() for every item, so the redaction gates hold for the nested case too. All four Endpoints entries are now verified=True; all four tools are now registered. 54 READ / 55 combined become 58 READ / 59 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — DNS Forwarder/Resolver extras LAB-verified and registered (P1 Batch C). services/dns_forwarder/ host_overrides, services/dns_resolver/domain_overrides, and services/dns_resolver/access_lists were re-checked against the pinned schema for secret fields (none found — DNSForwarderHostOverride, DNSResolverDomainOverride, and DNSResolverAccessList are all address/policy data, no credential material) and modeled following the existing shipped DnsResolverHostOverride precedent: full field visibility, no include_identifying_metadata redaction, since address/network data is core content for this capability class (the same rationale already documented for DhcpServer). PfSenseClient. get_dns_forwarder_host_overrides()/get_dns_resolver_domain_overrides()/ get_dns_resolver_access_lists(), their models, and their Endpoints entries were implemented and LAB-verified — all three returned zero configured objects (ENDPOINT_VERIFIED; no package required, base pfSense/dnsmasq/Unbound features). All three Endpoints entries are now verified=True; all three tools are now registered. 58 READ / 59 combined become 61 READ / 62 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — interface extras LAB-verified and registered (P1 Batch D). interface/available_interfaces, interface/gres, and interface/laggs were re-checked against the pinned schema for secret fields (none found). AvailableInterface.mac and 7 of InterfaceGRE's 11 fields (tunnel-endpoint addresses) are redacted by default, matching InterfaceStatus.macaddr and RoutingStaticRoute's established conventions; InterfaceLAGG's members/laggif stay visible, matching InterfaceBridge's established no-redaction precedent. PfSenseClient. get_interface_available_interfaces()/get_interface_gres()/ get_interface_laggs(), their models, and their Endpoints entries were implemented, offline-tested, and left unregistered for one commit (matching the P1 Batch A WireGuard precedent) before LAB verification. LAB-verified: interface/available_interfaces returned FIELD_MODEL_LIVE_VERIFIED — 2 real populated objects (the LAB's actual vtnet0/vtnet1 WAN/LAN interfaces), with redaction confirmed against real data (default call returned mac=None for both; include_identifying_metadata=True returned the literal addresses); interface/gres and interface/laggs both returned zero configured objects (ENDPOINT_VERIFIED only). All three Endpoints entries are now verified=True; all three tools are now registered. 61 READ / 62 combined become 64 READ / 65 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — routing/DHCP extras LAB-verified and registered (P1 Batch E). routing/gateway/groups, routing/gateway/default, services/dhcp_relay, services/dhcp_server/address_pools, and services/dhcp_server/custom_options were re-checked against the pinned schema for secret fields (none found). RoutingGatewayGroupPriority.gateway/.virtual_ip and DefaultGateway.defaultgw4/.defaultgw6 (gateway name references) and DHCPRelay.server (literal relay target addresses) are redacted by default, matching RoutingStaticRoute.gateway and GatewayConfig.gateway's established conventions. DHCPServerAddressPool/DHCPServerCustomOption are schema-declared children of DHCPServer (Parent model: DHCPServer) and follow that resource's own established no-redaction convention instead. PfSenseClient. get_routing_gateway_groups()/get_routing_gateway_default()/ get_dhcp_relay()/get_dhcp_server_address_pools()/ get_dhcp_server_custom_options(), their models, and their Endpoints entries were implemented, offline-tested, and left unregistered for one commit (matching the P1 Batch A WireGuard precedent) before LAB verification. LAB verification found a genuine CE 2.9.0 nullability discrepancy — DHCPRelay.interface returned null on the LAB's unconfigured DHCP Relay despite the pinned schema declaring it nullable: false — fixed by widening the field before promoting, matching the SystemRestApiVersion.install_version/ DhcpServer precedent. All five candidates reached ENDPOINT_VERIFIED (zero configured gateway groups/address pools/custom options; routing/gateway/default and services/dhcp_relay both returned real singleton objects but with every field null/disabled on this LAB — no populated non-null value observed). All five Endpoints entries are now verified=True; all five tools are now registered. 64 READ / 65 combined become 69 READ / 70 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — system identity/config cluster LAB-verified and registered (P1 Batch F). system/hostname, system/timezone, system/dns, system/console, and system/webgui/settings were re-checked against the pinned schema for secret fields (none found). SystemHostname.hostname/.domain are redacted by default (a conservative-posture judgment call, not a schema-confirmed secret — they identify the specific managed appliance/network) and SystemDNS.dnsserver (literal DNS server addresses) is redacted by default, matching RoutingStaticRoute.gateway/GatewayConfig.gateway's established conventions. WebGUISettings was independently re-verified secret-free during this batch's own re-check (protocol/port/ sslcertref only — sslcertref is a certificate reference, not key material). PfSenseClient.get_system_hostname()/get_system_timezone()/ get_system_dns()/get_system_console()/get_system_webgui_settings(), their models, and their Endpoints entries were implemented, offline-tested, and left unregistered for one commit (matching the P1 Batch A WireGuard precedent) before LAB verification. LAB verification found real populated data for all five: system/hostname returned FIELD_MODEL_LIVE_VERIFIED (hostname="pfSenseLAB", domain="test.arpa"), system/timezone returned FIELD_MODEL_LIVE_VERIFIED ("Etc/UTC"), system/console returned FIELD_MODEL_LIVE_VERIFIED (passwd_protect_console=False), system/webgui/settings returned FIELD_MODEL_LIVE_VERIFIED (a real populated sslcertref), and system/dns returned ENDPOINT_VERIFIED (dnsserver/dnslocalhost both null — no remote DNS servers configured on this LAB). All five Endpoints entries are now verified=True; all five tools are now registered. 69 READ / 70 combined become 74 READ / 75 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — REST API + PKI metadata LAB-verified and registered (P1 Batch G). system/restapi/access_list, system/crls, and system/package/available were re-checked against the pinned schema for secret fields. RESTAPIAccessListEntry.network (the REST API's own literal IP allow/deny CIDR) is redacted by default, matching GatewayConfig.gateway's established convention. Found during re-verification that CertificateRevocationListRevokedCertificate has five schema fields marked writeOnly: truecrt, caref, descr, type, and prv, confirmed to be the revoked certificate's X509 private key — none of these are ever present in a real GET response, and none are modeled, matching the CertificateAuthority.prv/SystemCertificate.prv precedent exactly rather than trusting the schema's writeOnly promise alone (proven by construction — an offline test injects prv into the raw response in-memory and confirms it is absent from the parsed model, never in a committed fixture). PfSenseClient.get_system_restapi_access_list()/ get_system_crls()/get_system_package_available(), their models, and their Endpoints entries were implemented, offline-tested, and left unregistered for one commit (matching the P1 Batch A WireGuard precedent) before LAB verification. LAB verification found real populated data: system/restapi/access_list returned FIELD_MODEL_LIVE_VERIFIED (2 real entries, the default allow-all IPv4/IPv6 rules; redaction confirmed against real data — default call returned network=None for both), system/crls returned ENDPOINT_VERIFIED (zero configured CRLs on this LAB), and system/package/available returned FIELD_MODEL_LIVE_VERIFIED (69 real available packages). All three Endpoints entries are now verified=True; all three tools are now registered. 74 READ / 75 combined become 77 READ / 78 combined throughout this document as of this pass.

2026-08-21 (same day, later still) — service/traffic policy cluster, partial (P1 Batch H). firewall/traffic_shapers, services/freeradius/interfaces, services/freeradius/macs, and services/service_watchdogs were re-checked against the pinned schema for secret fields (none found). firewall/traffic_shapers requires no package (base pfSense feature); the other three require pfSense-pkg-freeradius3/pfSense-pkg-Service_Watchdog, confirmed NOT installed on this LAB via the already-shipped pfsense_get_system_packages tool (only pfSense-pkg-WireGuard is installed) — implemented and offline-tested per the package-conditional-candidate rule, but left verified=False and unregistered pending an owner decision on installing either package (only pfSense-pkg-WireGuard installation was ever explicitly authorized). TrafficShaper/TrafficShaperQueue carry no redaction (pure QoS/bandwidth-shaping configuration, no addresses); TrafficShaperQueue.queue is schema-confirmed to embed full TrafficShaperQueue objects and is constructed through that model's own from_api() for every item, with 21 of its 27 fields treated as genuinely possibly-absent via .get() (each documented as only available for a specific scheduler type or sibling boolean flag). FreeRADIUSInterface.addr and FreeRADIUSMAC.mac/5 framed_* address fields are redacted by default once registered, matching GatewayConfig.gateway/InterfaceStatus.macaddr's established conventions. firewall/traffic_shapers LAB-verified ENDPOINT_VERIFIED (zero configured traffic shapers on this LAB); its Endpoints entry is now verified=True and its tool is now registered. 77 READ / 78 combined become 78 READ / 79 combined throughout this document as of this pass. The FreeRADIUS/Service_Watchdog trio remains implemented, offline-tested, and unregistered pending the owner's package-installation decision.

2026-08-21 (same day, later still) — IPsec Phase 2 + encryption capability lists LAB-verified and registered (P1 Batch I). vpn/ipsec/phase2s, vpn/ipsec/phase1/encryptions, and vpn/ipsec/phase2/encryptions were re-checked against the pinned schema for secret fields. Re-confirmed the IPsec PSK lives only on IPsecPhase1, already REJECTed separately — no secret material is present on Phase 2 itself. IPsecPhase2.localid_address/ .natlocalid_address/.remoteid_address/.pinghost (endpoint and monitoring target addresses) are redacted by default, matching RoutingStaticRoute.gateway's established convention. encryption_algorithm_option is schema-documented as only available when protocol is 'esp' and is treated as genuinely possibly-absent via .get(), matching the InterfaceLAGG precedent; it is schema-confirmed to embed full IPsecPhase2Encryption objects and is constructed through that model's own from_api() for every item. IPsecPhase1Encryption/IPsecPhase2Encryption are pure algorithm/cipher capability reference data, no redaction needed. PfSenseClient.get_vpn_ipsec_phase2s()/ get_vpn_ipsec_phase1_encryptions()/get_vpn_ipsec_phase2_encryptions(), their models, and their Endpoints entries were implemented, offline-tested, and left unregistered for one commit (matching the P1 Batch A WireGuard precedent) before LAB verification. All three LAB-verified ENDPOINT_VERIFIED (zero configured Phase 2 entries; the encryption capability lists were also empty on this LAB, since no IPsec Phase 1 is configured to derive options from). All three Endpoints entries are now verified=True; all three tools are now registered. 78 READ / 79 combined become 81 READ / 82 combined throughout this document as of this pass.

OpenVPN server config + client-specific overrides LAB-verified and registered (P1 Batch J). vpn/openvpn/servers and vpn/openvpn/csos were re-checked against the pinned schema for secret fields — neither OpenVPNServer nor OpenVPNClientSpecificOverride has any field marked writeOnly, unlike the Batch G CRL case. caref/certref are CA/certificate references, not certificate/key material. tlsauth_keydir is re-confirmed (a fourth time across sessions) to be a direction-flag enum, not key material. Network/address fields (tunnel/local/remote networks, DNS/NTP/WINS servers, server-bridge DHCP range on the server model; common name plus the same address-field set on the client-specific-override model) are redacted by default, matching RoutingStaticRoute.gateway's established convention. The singular vpn/openvpn/server endpoint is redundant with the plural vpn/openvpn/servers (same model) and is deliberately not implemented, matching the established NAT-mappings precedent. PfSenseClient.get_vpn_openvpn_servers()/ get_vpn_openvpn_csos(), their models, and their Endpoints entries were implemented, offline-tested, and left unregistered for one commit (matching the P1 Batch A WireGuard precedent) before LAB verification. Both LAB-verified ENDPOINT_VERIFIED (zero configured OpenVPN servers and zero client-specific overrides on this LAB). Both Endpoints entries are now verified=True; both tools are now registered. 81 READ / 82 combined become 83 READ / 84 combined throughout this document as of this pass.

Implementation Phase B (2026-08-17): every value below is now reproduced by real, tested, pure production code — src/pfsense_mcp/security_privileges.py's read_profile_requirements()/write_protected_profile_requirements() plus resolve_privilege()'s schema+source cross-check — rather than being a hand-maintained table that could silently drift from the actual codebase. This document's own values remain the regression evidence those functions are tested against (tests/test_security_privileges.py), not a second, independent source of truth.

Implementation Phase C (2026-08-17): this document's values are now also the exact target privilege set src/pfsense_mcp/ security_bootstrap_engine.py's provision_service_account() grants — never a second, hard-coded copy; the engine calls the same read_profile_requirements()/write_protected_profile_requirements() functions this matrix's own regression tests exercise. Still offline-tested only; see ADR-033's "Implementation Phase C" section for the full provisioning-sequence writeup and its GO/NO-GO for a future live-validation phase.

Method (evidence tier 1 of 3 — see ADR-033 §"Evidence")

Every privilege string below is the output of the installed pfSense REST API package's own Core/Endpoint.inc::get_method_priv_name():

private function get_method_priv_name(string $method): string {
    $priv_name_prefix = str_replace('/', '-', $this->url) . '-';
    $priv_name_prefix = str_replace('_', '-', $priv_name_prefix);
    if (str_starts_with($priv_name_prefix, '-')) {
        $priv_name_prefix = substr($priv_name_prefix, offset: 1);
    }
    return $priv_name_prefix . strtolower($method);
}

(pfrest/pfSense-pkg-RESTAPI, Core/Endpoint.inc, confirmed byte-identical across every tag from v2.7.7 through v2.10.0 — the current latest tag as of this research pass, 2026-08-17. The algorithm has not changed across this project's entire lifetime.)

Concretely: take the endpoint's URL (e.g. /api/v2/status/system), replace every / and _ with -, strip the resulting leading -, append the lowercase HTTP method. /api/v2/status/dhcp_server/leases (GET) → api-v2-status-dhcp-server-leases-get.

Authorization is an ANY-match, not an ALL-match. Core/Auth.inc's authorize() checks array_intersect($this->required_privileges, $this->client_privileges) — holding either page-all or the endpoint's own narrow privilege is sufficient. This is what makes a true least-privilege identity (holding only the narrow privileges, never page-all) architecturally valid, not merely a documentation convention this project has chosen to follow.

requires_page_all_privilege exists and matters. A small number of endpoints package-wide (1 of 268 checked this pass: /api/v2/system/restapi/settings/sync, a POST-only sync action) hard-code page-all as the only accepted privilege — no narrow alternative exists for them. None of the 94 endpoints this project's 95 READ tools use require page-all, confirmed by direct inspection of every matching Endpoints/*.inc file at the pinned tag (including the two outbound-NAT/1:1-NAT mapping endpoints, the interface-VLAN/static-route/ group, firewall-schedule/virtual-IP, REST-API-version, and certificate-authority endpoints, the IPsec SA/child-SA and WireGuard tunnel/peer status endpoints, the OpenVPN server/client/connection/ route status endpoints, the DNS Forwarder host-override/DNS Resolver domain-override/access-list endpoints, the interface available- interfaces/GRE/LAGG endpoints, the routing-gateway-group/default and DHCP relay/address-pool/custom-option endpoints, the system hostname/timezone/DNS/console/webgui-settings endpoints, the REST API access-list/CRLs/available-packages endpoints, firewall traffic shapers, the IPsec Phase 2/encryption capability lists trio, and the OpenVPN server-config/client-specific-override pair, each of which offers a narrow alternative alongside page-all). This must be re-checked for any future tool added against a new endpoint — it is not a general guarantee.

Evidence tier 2: live OpenAPI schema corroboration

Independently, the REST API package's own Schemas/OpenAPISchema.inc embeds each operation's exact allowed-privileges list directly in the generated schema's description field: "**Allowed privileges**: [ page-all, api-v2-status-system-get ]". Every single privilege string below was cross-checked against this exact text in a real OpenAPI schema previously captured live from the disposable LAB appliance (pfsense_openapi_schema.json, captured during the ADR-026 least-privilege provisioning work) — not merely computed from the algorithm above. All 42 matched exactly; zero mismatches, zero endpoints missing from the live schema. The two outbound-NAT/1:1-NAT mapping endpoints added 2026-08-20 were independently cross-checked the same way, but against a live schema freshly fetched from the production appliance during an owner-authorized live READ verification, not the original LAB capture — also an exact match, zero mismatches (43 of 43 now, including these two).

This corroboration matters for a second reason beyond double-checking arithmetic: the live schema is generated by the actually installed package on the actual target appliance, so agreement between the algorithm (run against pinned GitHub source) and the live schema (generated by the real running instance) is itself evidence that the installed version's privilege-naming behavior matches the pinned source this document reasons about. The interface-VLAN/static-route pair, the interface-group/firewall-schedule/REST-API-version trio, the firewall-virtual-IP/certificate-authority pair, the IPsec SA/child-SA status pair, the WireGuard tunnel/peer status pair, the OpenVPN server/client/connection/route status four, the DNS Forwarder/ Resolver extras three, the interface available-interfaces/GRE/LAGG three, the routing-gateway-group/default and DHCP relay/address- pool/custom-option five, the system hostname/timezone/DNS/ console/webgui-settings five, the REST API access-list/CRLs/ available-packages three, firewall traffic shapers, the IPsec Phase 2/encryption capability lists trio, and the OpenVPN server-config/client-specific-override pair, were cross-checked against a live schema freshly fetched from the LAB appliance (pfsense-test.lab.invalid) during owner-authorized LAB READ verification passes — also an exact match, zero mismatches (83 of 83 now, including all forty). The IPsec, WireGuard, OpenVPN, DNS, interface-extras, routing/DHCP, system-identity, REST-API/PKI, traffic-shaper/IPsec-Phase-2, and OpenVPN-server-config groups' LAB cross-checks were against the upgraded pfSense CE 2.9.0 appliance specifically; its REST API package schema remained an exact 267-path match despite the platform upgrade, both before and after installing pfSense-pkg-WireGuard.

READ privilege matrix (95 tools)

MCP tool PfSenseClient method pfSense endpoint Required privilege Live-confirmed
pfsense_acme_settings get_acme_settings GET /api/v2/services/acme/settings api-v2-services-acme-settings-get
pfsense_arp_table get_arp_table GET /api/v2/diagnostics/arp_table api-v2-diagnostics-arp-table-get
pfsense_auth_keys get_auth_keys GET /api/v2/auth/keys api-v2-auth-keys-get
pfsense_bind_settings get_bind_settings GET /api/v2/services/bind/settings api-v2-services-bind-settings-get
pfsense_carp_status get_carp_status GET /api/v2/status/carp api-v2-status-carp-get
pfsense_cron_jobs get_cron_jobs GET /api/v2/services/cron/jobs api-v2-services-cron-jobs-get
pfsense_dhcp_leases get_dhcp_leases GET /api/v2/status/dhcp_server/leases api-v2-status-dhcp-server-leases-get
pfsense_dhcp_relay get_dhcp_relay GET /api/v2/services/dhcp_relay api-v2-services-dhcp-relay-get
pfsense_dhcp_server_address_pools get_dhcp_server_address_pools GET /api/v2/services/dhcp_server/address_pools api-v2-services-dhcp-server-address-pools-get
pfsense_dhcp_server_custom_options get_dhcp_server_custom_options GET /api/v2/services/dhcp_server/custom_options api-v2-services-dhcp-server-custom-options-get
pfsense_dhcp_servers get_dhcp_servers GET /api/v2/services/dhcp_servers api-v2-services-dhcp-servers-get
pfsense_dhcp_static_mappings get_dhcp_static_mappings GET /api/v2/services/dhcp_server/static_mappings api-v2-services-dhcp-server-static-mappings-get
pfsense_diagnostics_tables get_diagnostics_tables GET /api/v2/diagnostics/tables api-v2-diagnostics-tables-get
pfsense_dns_forwarder_host_overrides get_dns_forwarder_host_overrides GET /api/v2/services/dns_forwarder/host_overrides api-v2-services-dns-forwarder-host-overrides-get
pfsense_dns_resolver_access_lists get_dns_resolver_access_lists GET /api/v2/services/dns_resolver/access_lists api-v2-services-dns-resolver-access-lists-get
pfsense_dns_resolver_domain_overrides get_dns_resolver_domain_overrides GET /api/v2/services/dns_resolver/domain_overrides api-v2-services-dns-resolver-domain-overrides-get
pfsense_dns_resolver_host_overrides get_dns_resolver_host_overrides GET /api/v2/services/dns_resolver/host_overrides api-v2-services-dns-resolver-host-overrides-get
pfsense_dns_resolver_settings get_dns_resolver_settings GET /api/v2/services/dns_resolver/settings api-v2-services-dns-resolver-settings-get
pfsense_email_notification_settings get_email_notification_settings GET /api/v2/system/notifications/email_settings api-v2-system-notifications-email-settings-get
pfsense_firewall_advanced_settings get_firewall_advanced_settings GET /api/v2/firewall/advanced_settings api-v2-firewall-advanced-settings-get
pfsense_firewall_aliases get_firewall_aliases GET /api/v2/firewall/aliases api-v2-firewall-aliases-get
pfsense_firewall_apply_status get_firewall_apply_status GET /api/v2/firewall/apply api-v2-firewall-apply-get
pfsense_firewall_nat_one_to_one_mappings get_firewall_nat_one_to_one_mappings GET /api/v2/firewall/nat/one_to_one/mappings api-v2-firewall-nat-one-to-one-mappings-get
pfsense_firewall_nat_outbound_mappings get_firewall_nat_outbound_mappings GET /api/v2/firewall/nat/outbound/mappings api-v2-firewall-nat-outbound-mappings-get
pfsense_firewall_nat_outbound_mode get_firewall_nat_outbound_mode GET /api/v2/firewall/nat/outbound/mode api-v2-firewall-nat-outbound-mode-get
pfsense_firewall_nat_port_forwards get_firewall_nat_port_forwards GET /api/v2/firewall/nat/port_forwards api-v2-firewall-nat-port-forwards-get
pfsense_firewall_rules get_firewall_rules GET /api/v2/firewall/rules api-v2-firewall-rules-get
pfsense_firewall_schedules get_firewall_schedules GET /api/v2/firewall/schedules api-v2-firewall-schedules-get
pfsense_firewall_states get_firewall_states GET /api/v2/firewall/states api-v2-firewall-states-get
pfsense_firewall_states_size get_firewall_states_size GET /api/v2/firewall/states/size api-v2-firewall-states-size-get
pfsense_firewall_traffic_shaper_limiters get_firewall_traffic_shaper_limiters GET /api/v2/firewall/traffic_shaper/limiters api-v2-firewall-traffic-shaper-limiters-get
pfsense_firewall_traffic_shapers get_firewall_traffic_shapers GET /api/v2/firewall/traffic_shapers api-v2-firewall-traffic-shapers-get
pfsense_firewall_virtual_ips get_firewall_virtual_ips GET /api/v2/firewall/virtual_ips api-v2-firewall-virtual-ips-get
pfsense_freeradius_eap get_freeradius_eap GET /api/v2/services/freeradius/eap api-v2-services-freeradius-eap-get
pfsense_gateway_status get_gateway_status GET /api/v2/status/gateways api-v2-status-gateways-get
pfsense_gateways get_gateways GET /api/v2/routing/gateways api-v2-routing-gateways-get
pfsense_interface_available_interfaces get_interface_available_interfaces GET /api/v2/interface/available_interfaces api-v2-interface-available-interfaces-get
pfsense_interface_bridges get_interface_bridges GET /api/v2/interface/bridges api-v2-interface-bridges-get
pfsense_interface_configs get_interface_configs GET /api/v2/interfaces api-v2-interfaces-get
pfsense_interface_gres get_interface_gres GET /api/v2/interface/gres api-v2-interface-gres-get
pfsense_interface_groups get_interface_groups GET /api/v2/interface/groups api-v2-interface-groups-get
pfsense_interface_laggs get_interface_laggs GET /api/v2/interface/laggs api-v2-interface-laggs-get
pfsense_interface_vlans get_interface_vlans GET /api/v2/interface/vlans api-v2-interface-vlans-get
pfsense_interfaces get_interfaces GET /api/v2/status/interfaces api-v2-status-interfaces-get
pfsense_mcp_info (none — local only) (no pfSense call) (none required) n/a
pfsense_ntp_settings get_ntp_settings GET /api/v2/services/ntp/settings api-v2-services-ntp-settings-get
pfsense_ntp_time_servers get_ntp_time_servers GET /api/v2/services/ntp/time_servers api-v2-services-ntp-time-servers-get
pfsense_routing_gateway_default get_routing_gateway_default GET /api/v2/routing/gateway/default api-v2-routing-gateway-default-get
pfsense_routing_gateway_groups get_routing_gateway_groups GET /api/v2/routing/gateway/groups api-v2-routing-gateway-groups-get
pfsense_routing_static_routes get_routing_static_routes GET /api/v2/routing/static_routes api-v2-routing-static-routes-get
pfsense_service_status get_service_status GET /api/v2/status/services api-v2-status-services-get
pfsense_ssh_settings get_ssh_settings GET /api/v2/services/ssh api-v2-services-ssh-get
pfsense_status_ipsec_child_sas get_status_ipsec_child_sas GET /api/v2/status/ipsec/child_sas api-v2-status-ipsec-child-sas-get
pfsense_status_ipsec_sas get_status_ipsec_sas GET /api/v2/status/ipsec/sas api-v2-status-ipsec-sas-get
pfsense_status_openvpn_clients get_status_openvpn_clients GET /api/v2/status/openvpn/clients api-v2-status-openvpn-clients-get
pfsense_status_openvpn_server_connections get_status_openvpn_server_connections GET /api/v2/status/openvpn/server/connections api-v2-status-openvpn-server-connections-get
pfsense_status_openvpn_server_routes get_status_openvpn_server_routes GET /api/v2/status/openvpn/server/routes api-v2-status-openvpn-server-routes-get
pfsense_status_openvpn_servers get_status_openvpn_servers GET /api/v2/status/openvpn/servers api-v2-status-openvpn-servers-get
pfsense_status_wireguard_peers get_status_wireguard_peers GET /api/v2/status/wireguard/peers api-v2-status-wireguard-peers-get
pfsense_status_wireguard_tunnels get_status_wireguard_tunnels GET /api/v2/status/wireguard/tunnels api-v2-status-wireguard-tunnels-get
pfsense_system_certificate_authorities get_system_certificate_authorities GET /api/v2/system/certificate_authorities api-v2-system-certificate-authorities-get
pfsense_system_certificates get_system_certificates GET /api/v2/system/certificates api-v2-system-certificates-get
pfsense_system_console get_system_console GET /api/v2/system/console api-v2-system-console-get
pfsense_system_crls get_system_crls GET /api/v2/system/crls api-v2-system-crls-get
pfsense_system_dns get_system_dns GET /api/v2/system/dns api-v2-system-dns-get
pfsense_system_hasync get_system_hasync GET /api/v2/system/hasync api-v2-system-hasync-get
pfsense_system_hostname get_system_hostname GET /api/v2/system/hostname api-v2-system-hostname-get
pfsense_system_package_available get_system_package_available GET /api/v2/system/package/available api-v2-system-package-available-get
pfsense_system_packages get_system_packages GET /api/v2/system/packages api-v2-system-packages-get
pfsense_system_restapi_access_list get_system_restapi_access_list GET /api/v2/system/restapi/access_list api-v2-system-restapi-access-list-get
pfsense_system_restapi_settings get_system_restapi_settings GET /api/v2/system/restapi/settings api-v2-system-restapi-settings-get
pfsense_system_restapi_version get_system_restapi_version GET /api/v2/system/restapi/version api-v2-system-restapi-version-get
pfsense_system_status get_system_status GET /api/v2/status/system api-v2-status-system-get
pfsense_system_timezone get_system_timezone GET /api/v2/system/timezone api-v2-system-timezone-get
pfsense_system_tunables get_system_tunables GET /api/v2/system/tunables api-v2-system-tunables-get
pfsense_system_version get_system_version GET /api/v2/system/version api-v2-system-version-get
pfsense_system_webgui_settings get_system_webgui_settings GET /api/v2/system/webgui/settings api-v2-system-webgui-settings-get
pfsense_user_groups get_user_groups GET /api/v2/user/groups api-v2-user-groups-get
pfsense_users get_users GET /api/v2/users api-v2-users-get
pfsense_vpn_ipsec_phase1_encryptions get_vpn_ipsec_phase1_encryptions GET /api/v2/vpn/ipsec/phase1/encryptions api-v2-vpn-ipsec-phase1-encryptions-get
pfsense_vpn_ipsec_phase2_encryptions get_vpn_ipsec_phase2_encryptions GET /api/v2/vpn/ipsec/phase2/encryptions api-v2-vpn-ipsec-phase2-encryptions-get
pfsense_vpn_ipsec_phase2s get_vpn_ipsec_phase2s GET /api/v2/vpn/ipsec/phase2s api-v2-vpn-ipsec-phase2s-get
pfsense_vpn_openvpn_csos get_vpn_openvpn_csos GET /api/v2/vpn/openvpn/csos api-v2-vpn-openvpn-csos-get
pfsense_vpn_openvpn_servers get_vpn_openvpn_servers GET /api/v2/vpn/openvpn/servers api-v2-vpn-openvpn-servers-get
pfsense_get_diagnostics_config_history_revisions get_config_history_revisions GET /api/v2/diagnostics/config_history/revisions api-v2-diagnostics-config-history-revisions-get
pfsense_get_status_logs_settings get_status_logs_settings GET /api/v2/status/logs/settings api-v2-status-logs-settings-get
pfsense_get_firewall_virtual_ip_apply_status get_firewall_virtual_ip_apply_status GET /api/v2/firewall/virtual_ip/apply api-v2-firewall-virtual-ip-apply-get
pfsense_get_interface_apply_status get_interface_apply_status GET /api/v2/interface/apply api-v2-interface-apply-get
pfsense_get_routing_apply_status get_routing_apply_status GET /api/v2/routing/apply api-v2-routing-apply-get
pfsense_get_dhcp_server_apply_status get_dhcp_server_apply_status GET /api/v2/services/dhcp_server/apply api-v2-services-dhcp-server-apply-get
pfsense_get_dns_forwarder_apply_status get_dns_forwarder_apply_status GET /api/v2/services/dns_forwarder/apply api-v2-services-dns-forwarder-apply-get
pfsense_get_dns_resolver_apply_status get_dns_resolver_apply_status GET /api/v2/services/dns_resolver/apply api-v2-services-dns-resolver-apply-get
pfsense_get_ipsec_apply_status get_ipsec_apply_status GET /api/v2/vpn/ipsec/apply api-v2-vpn-ipsec-apply-get
pfsense_get_wireguard_apply_status get_wireguard_apply_status GET /api/v2/vpn/wireguard/apply api-v2-vpn-wireguard-apply-get
pfsense_get_vpn_wireguard_tunnel_addresses get_vpn_wireguard_tunnel_addresses GET /api/v2/vpn/wireguard/tunnel/addresses api-v2-vpn-wireguard-tunnel-addresses-get

94 distinct privileges, one per tool, zero sharing between tools — confirmed programmatically (len(set(privileges)) == 94). A least-privilege READ-only identity holding exactly these 94 (never page-all) can serve every one of this project's 95 registered READ tools.

get_diagnostics_config_history_revisions's privilege (api-v2-diagnostics-config-history-revisions-get) was originally live-confirmed 2026-08-16 while the client method existed only as an internal, unregistered evidence-gathering utility (ADR-026 row 18) — see CHANGELOG.md's v0.6.0 entry for this promotion's own evidence trail (v0.6.0 Phase A qualification + Phase B Batch B). A fresh confirmatory live call was not obtained during Batch B's own session (LAB read-only service-account privilege scope did not yet include this endpoint at that time); it was independently live-reconfirmed during the Phase B completion pass below, once the account was properly provisioned.

2026-08-22 Phase B completion: after the read-only LAB service account (pfsense-mcp, id=2 — confirmed to be this project's intentionally-provisioned write_protected-profile service account, per AI_CONTEXT.md's 2026-08-19 ADR-033 checkpoint, not a misconfigured or excessively-privileged account) was synced from its stale 42-privilege snapshot (dating to that same 2026-08-19 provisioning) to the full current requirement, all 10 previously-pending Batch C/D/E candidates were live-verified in one pass: 9 returned an exact key-set match against their model (FIELD_MODEL_LIVE_VERIFIED); get_vpn_wireguard_tunnel_addresses returned 200, {"data": []} (ENDPOINT_VERIFIED — no tunnel addresses configured on this LAB, so the item shape itself was not exercised live; its field-safety argument rests on the Phase A schema/security review, not an observed populated item). One real defect was found and fixed during this pass, not merely assumed correct: LogSettings needed 18 fields (including sourceip, missed in an earlier partial fix) widened from the schema's declared nullable: false to Optional, since this LAB genuinely returns null for every log category never explicitly configured — see src/pfsense_mcp/models/log_settings.py's docstring for the full before/after account.

WRITE privilege matrix (set_firewall_alias_description_v1)

Re-derived independently this pass (not merely copied forward) from the same pinned source and the same live schema, as the task's "do not assume previously documented privilege IDs remain correct" instruction required — and found unchanged from the values already live-provisioned and live-verified in ADR-026:

Purpose pfSense endpoint Required privilege Live-confirmed
Read current alias state before mutation GET /api/v2/firewall/aliases api-v2-firewall-aliases-get
The mutation itself PATCH /api/v2/firewall/alias api-v2-firewall-alias-patch
System status (production runtime dependency) GET /api/v2/status/system api-v2-status-system-get
HA-sync check (conditional — see ADR-026) GET /api/v2/system/hasync api-v2-system-hasync-get

These 4, plus the already-documented, already-revoked, one-time bootstrap privilege api-v2-auth-key-post (needed only for a new identity to self-generate its first API key — see ADR-033 §"Bootstrap security model" and the historical reports-ai/reviews/SLICE6_LEAST_PRIVILEGE_PROVISIONING_2026-08-16.md transcript), are the complete set this project has ever needed to provision for WRITE, live-tested twice against the disposable LAB appliance.

Managed READ-only service account (pfsense-mcp-readonly)

POST-v1.0 MANAGED READ-ONLY DEFENSE IN DEPTH mission (2026-08-29): the same ADR-033 bootstrap engine that provisions write_protected's pfsense-mcp account (TargetProfile.WRITE_PROTECTED) already supported TargetProfile.READ_ONLY from its original implementation -- provision_service_account()'s own test suite defaults to it. What was missing was the composition/orchestration/CLI layer above the engine; that gap is now closed by security_readonly_admin_composition.py/security_readonly_bootstrap_ recovery.py/security_bootstrap_orchestration.run_readonly_bootstrap_ from_environment(), reachable via pfsense-mcp-security bootstrap --target-profile read_only.

The provisioned account is granted exactly the 94 READ privileges this document's own READ privilege matrix (above) already lists -- never page-all, never the WRITE-exclusive api-v2-firewall-alias-patch, never the temporary bootstrap-only api-v2-auth-key-post (revoked before the transaction completes, identically to write_protected's own ceremony). It is a separate, fixed, dedicated account (pfsense-mcp-readonly, distinct from write_protected's pfsense-mcp) with its own journal/lock/custody namespace, its own recovery module (security_readonly_bootstrap_recovery.py), and its own custody env var (PFSENSE_READONLY_SERVICE_API_KEY_FILE) -- the two ceremonies can never collide, be confused for each other, or share state, by construction (proven in tests/test_security_readonly_admin_composition.py::test_namespace_never_collides_with_write_protected_for_the_identical_target and the orchestration/journal-level equivalents).

This closes the credential-layer gap the prior reports-ai/POST_V1_0_SECURITY_BOUNDARY_AUDIT_2026-08-29.md audit found: an operator who chooses managed provisioning (rather than bring-your-own-key) for read_only now gets a pfSense credential that is itself incapable of any WRITE operation, on top of the pre-existing MCP-layer guarantee (0 default-reachable WRITE tools) -- true defense in depth. BYOK remains available and unchanged as the advanced path; its documented caveat (the product cannot verify an operator-supplied key's own privilege scope) is unaffected by this addition.

Wired into the interactive setup wizard (POST-v1.0 MANAGED READ-ONLY WIZARD INTEGRATION mission, 2026-08-29) -- mirroring exactly how write_protected's own bootstrap subcommand existed and was live-verified before setup apply --capability-posture write_protected was later extended to compose it. setup's own Account step now offers "Create a dedicated read-only account [Recommended]" (managed) alongside "Use an existing API key [Advanced]" (BYOK) for read_only, and setup apply --read-only-account-mode managed composes the same run_readonly_bootstrap_from_environment() standalone bootstrap --target-profile read_only already used -- never a second, independent provisioning engine, and never mutating during bare setup, in either mode, always. The managed-vs-BYOK choice is security-bound into the plan digest and confirmation token, not merely presentational -- a plan/token reviewed for one mode cannot authorize an apply in the other. Existing BYOK installations are unaffected: read_only_account_mode defaults to byo, so every pre-existing scripted invocation that omits the new flag behaves exactly as it always has.

Combined minimum set, READ + existing WRITE

The 4 WRITE privileges are a strict subset of the 94 READ privileges except for api-v2-firewall-alias-patch (the mutation itself, obviously WRITE-only) — firewall-aliases-get, status-system-get, and system-hasync-get are already required for the READ tools pfsense_firewall_aliases, pfsense_system_status, and pfsense_system_hasync respectively. A write_protected-profile identity therefore needs exactly 95 distinct privileges: the 94 READ privileges plus the one additional api-v2-firewall-alias-patch. This is independently confirmed live: the LAB's own pfsense-mcp service account (id=2), synced 2026-08-22 to exactly this set plus its pre-existing api-v2-firewall-alias-patch, holds 95 privileges total.