/*_############################################################################
  _##
  _##  SNMP4J-Agent 3 - DefaultAgentAuditPolicy.java
  _##
  _##  Copyright (C) 2005-2026  Frank Fock (SNMP4J.org)
  _##
  _##  Licensed under the Apache License, Version 2.0 (the "License");
  _##  you may not use this file except in compliance with the License.
  _##  You may obtain a copy of the License at
  _##
  _##      http://www.apache.org/licenses/LICENSE-2.0
  _##
  _##  Unless required by applicable law or agreed to in writing, software
  _##  distributed under the License is distributed on an "AS IS" BASIS,
  _##  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  _##  See the License for the specific language governing permissions and
  _##  limitations under the License.
  _##
  _##########################################################################*/

package org.snmp4j.agent.audit;

import org.snmp4j.agent.*;
import org.snmp4j.agent.request.Request;
import org.snmp4j.log.LogAdapter;
import org.snmp4j.log.LogFactory;
import org.snmp4j.security.SecurityLevel;
import org.snmp4j.smi.*;
import org.snmp4j.transport.TransportAuditEvent;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.Serializable;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * The default {@link AgentAuditPolicy} implementation for SNMP4J-Agent. It enforces the access
 * restrictions defined by an {@link AgentPolicyProfile} and audits (logs) the relevant transport,
 * file and managed object access events according to the configured {@link AgentAuditLevel}:
 * <ul>
 *     <li>{@link AgentAuditLevel#OFF}: auditing is disabled. No event is logged and all operations
 *     are permitted (this policy does not interfere).</li>
 *     <li>{@link AgentAuditLevel#LOG}: every audited operation is logged (at INFO level). No operation
 *     is denied.</li>
 *     <li>{@link AgentAuditLevel#WARN}: like {@code LOG}, but operations that violate the policy
 *     profile are logged as warnings. No operation is denied.</li>
 *     <li>{@link AgentAuditLevel#RESTRICT}: like {@code WARN}, and in addition operations that violate
 *     the policy profile are denied where this is possible, i.e. for the boolean returning
 *     {@link #transportAudit(TransportAuditEvent)} and {@link #fileAudit(FileAuditEvent)} methods.</li>
 * </ul>
 * Managed object access ({@link #lookupEvent(MOServerLookupEvent)} and
 * {@link #queryEvent(MOServerLookupEvent)}) cannot be denied through these (void) listener callbacks -
 * the actual enforcement of managed object access is performed by the View-based Access Control Model
 * (VACM). This policy therefore audits managed object access and logs policy violations (as warnings
 * on {@code WARN} and {@code RESTRICT} level) so that misconfigurations and VACM gaps become visible.
 * <p>
 * Audit records are written through a {@link LogAdapter} obtained from {@link LogFactory}. In addition,
 * an optional {@link OutputStream} (backed for example by a file, {@code System.out} or
 * {@code System.err}) can be supplied to receive a copy of every audit record independent of the
 * logging configuration.
 *
 * @author Frank Fock
 * @version 3.12.0
 * @since 3.12.0
 */
public class DefaultAgentAuditPolicy implements AgentAuditPolicy, Serializable {

    private static final long serialVersionUID = 1L;

    private static final LogAdapter logger = LogFactory.getLogger(DefaultAgentAuditPolicy.class);

    private static final DateTimeFormatter TS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

    private final AgentPolicyProfile agentPolicyProfile;
    private final AgentAuditLevel auditLevel;

    public static final DefaultAgentAuditPolicy OFF =
            new DefaultAgentAuditPolicy(null, AgentAuditLevel.OFF);
    public static final DefaultAgentAuditPolicy LOG =
            new DefaultAgentAuditPolicy(AgentPolicyProfile.DEFAULT, AgentAuditLevel.LOG);
    public static final DefaultAgentAuditPolicy DEFAULT =
            new DefaultAgentAuditPolicy(AgentPolicyProfile.DEFAULT, AgentAuditLevel.RESTRICT);
    public static final DefaultAgentAuditPolicy CONFIDENTIAL =
            new DefaultAgentAuditPolicy(AgentPolicyProfile.CONFIDENTIAL, AgentAuditLevel.RESTRICT);
    public static final DefaultAgentAuditPolicy WARN =
            new DefaultAgentAuditPolicy(AgentPolicyProfile.DEFAULT, AgentAuditLevel.WARN);
    public static final DefaultAgentAuditPolicy TEST =
            new DefaultAgentAuditPolicy(AgentPolicyProfile.LOCALHOST, AgentAuditLevel.RESTRICT);

    /**
     * Optional audit output stream (e.g. a file, {@code System.out} or {@code System.err}). It is
     * {@code transient} because streams are not serializable; it has to be re-supplied after
     * deserialization via {@link #setAuditOutputStream(OutputStream)}.
     */
    private transient PrintStream auditOut;

    /**
     * Creates a {@code DefaultAgentAuditPolicy} that logs audit records through the {@link LogFactory}
     * logging only.
     *
     * @param agentPolicyProfile
     *    the policy profile that defines the access restrictions to enforce, or {@code null} to permit
     *    all operations (audit only).
     * @param auditLevel
     *    the audit level to apply, or {@code null} which is interpreted as {@link AgentAuditLevel#OFF}.
     */
    public DefaultAgentAuditPolicy(AgentPolicyProfile agentPolicyProfile, AgentAuditLevel auditLevel) {
        this(agentPolicyProfile, auditLevel, null);
    }

    /**
     * Creates a {@code DefaultAgentAuditPolicy} that logs audit records through the {@link LogFactory}
     * logging and, in addition, writes a copy of every audit record to the supplied
     * {@link OutputStream}.
     *
     * @param agentPolicyProfile
     *    the policy profile that defines the access restrictions to enforce, or {@code null} to permit
     *    all operations (audit only).
     * @param auditLevel
     *    the audit level to apply, or {@code null} which is interpreted as {@link AgentAuditLevel#OFF}.
     * @param auditOutputStream
     *    an optional output stream that receives a copy of every audit record (for example a
     *    {@code FileOutputStream}, {@code System.out} or {@code System.err}), or {@code null} for
     *    logging through {@link LogFactory} only.
     */
    public DefaultAgentAuditPolicy(AgentPolicyProfile agentPolicyProfile, AgentAuditLevel auditLevel,
                                   OutputStream auditOutputStream) {
        this.agentPolicyProfile = agentPolicyProfile;
        this.auditLevel = (auditLevel == null) ? AgentAuditLevel.OFF : auditLevel;
        setAuditOutputStream(auditOutputStream);
    }

    @Override
    public AgentAuditLevel getAuditLevel() {
        return auditLevel;
    }

    /**
     * Gets the policy profile that defines the access restrictions enforced by this policy.
     * @return
     *    the {@link AgentPolicyProfile} or {@code null} if all operations are permitted (audit only).
     */
    public AgentPolicyProfile getAgentPolicyProfile() {
        return agentPolicyProfile;
    }

    /**
     * Sets (or clears) the optional output stream that receives a copy of every audit record. This can
     * be used to write the audit trail to a dedicated file or to {@code System.out}/{@code System.err}
     * in clear text, independent of the {@link LogFactory} logging configuration.
     *
     * @param auditOutputStream
     *    the output stream to use, or {@code null} to disable audit stream output.
     */
    public final void setAuditOutputStream(OutputStream auditOutputStream) {
        if (auditOutputStream == null) {
            this.auditOut = null;
        } else if (auditOutputStream instanceof PrintStream) {
            this.auditOut = (PrintStream) auditOutputStream;
        } else {
            this.auditOut = new PrintStream(auditOutputStream, true);
        }
    }

    /*-------------------------------------------------------------------------------------------------
     * Listener interface methods
     *-----------------------------------------------------------------------------------------------*/

    @Override
    public boolean fileAudit(FileAuditEvent event) {
        if (auditLevel == AgentAuditLevel.OFF) {
            return true;
        }
        boolean permittedByPolicy = isFileOperationAllowed(event);
        return logDecision(permittedByPolicy, true, "FILE", event);
    }

    @Override
    public boolean transportAudit(TransportAuditEvent event) {
        if (auditLevel == AgentAuditLevel.OFF) {
            return true;
        }
        boolean permittedByPolicy = isTransportOperationAllowed(event);
        return logDecision(permittedByPolicy, true, "TRANSPORT", event);
    }

    @Override
    public void lookupEvent(MOServerLookupEvent event) {
        if (auditLevel == AgentAuditLevel.OFF) {
            return;
        }
        boolean permittedByPolicy = isManagedObjectAccessAllowed(event);
        // Managed object access cannot be denied through this (void) callback - it is enforced by VACM.
        // We only audit it and report policy violations.
        logDecision(permittedByPolicy, false, "OBJECT", describe(event));
    }

    @Override
    public void queryEvent(MOServerLookupEvent event) {
        // A query event is fired before a managed object is matched against a query (e.g. to update
        // dynamic content). It is not an access decision, so it is only traced at debug level.
        if ((auditLevel == AgentAuditLevel.OFF) || (!logger.isDebugEnabled())) {
            return;
        }
        logger.debug("Agent audit [OBJECT-QUERY]: " + describe(event));
    }

    /*-------------------------------------------------------------------------------------------------
     * Policy evaluation - transport
     *-----------------------------------------------------------------------------------------------*/

    /**
     * Evaluates whether the supplied transport operation is permitted by the {@link AgentPolicyProfile}.
     *
     * @param event
     *    the transport audit event to evaluate.
     * @return
     *    {@code true} if the operation is permitted by the policy profile, {@code false} otherwise.
     */
    protected boolean isTransportOperationAllowed(TransportAuditEvent event) {
        AgentPolicyProfile profile = agentPolicyProfile;
        if (profile == null) {
            return true;
        }
        switch (event.getOperation()) {
            case LISTEN:
            case CLOSE:
                // Binding/closing the agent's own listen sockets is a local lifecycle operation.
                return true;
            case SEND:
            case RECEIVE:
            default:
                return isPeerAddressAllowed(profile, event.getPeerAddress());
        }
    }

    private boolean isPeerAddressAllowed(AgentPolicyProfile profile, Address peerAddress) {
        AgentPolicyProfile.NetworkAccess networkAccess = profile.getNetworkAccess();
        if ((networkAccess == null) ||
                (networkAccess == AgentPolicyProfile.NetworkAccess.REMOTE_NETWORK_UNLIMITED)) {
            return true;
        }
        boolean local = isLocalAddress(peerAddress, networkAccess != AgentPolicyProfile.NetworkAccess.SITE_LOCAL_NETWORK);
        switch (networkAccess) {
            case LOOPBACK:
            case SITE_LOCAL_NETWORK:
                return local;
            case REMOTE_NETWORK_LIMITED:
                return local || isExplicitlyAllowedAddress(profile, peerAddress);
            default:
                return true;
        }
    }

    private static boolean isLocalAddress(Address address, boolean onlyLoopback) {
        InetAddress inetAddress = toInetAddress(address);
        if (inetAddress == null) {
            // Unknown/non-IP address: treat conservatively as non-local.
            return false;
        }
        return inetAddress.isLoopbackAddress() || (!onlyLoopback &&
                (inetAddress.isLinkLocalAddress() || inetAddress.isSiteLocalAddress()));
    }

    private static boolean isLoopbackAddress(Address address) {
        InetAddress inetAddress = toInetAddress(address);
        if (inetAddress == null) {
            // Unknown/non-IP address: treat conservatively as non-local.
            return false;
        }
        return inetAddress.isLoopbackAddress();
    }

    private static InetAddress toInetAddress(Address address) {
        return (address instanceof TransportIpAddress) ? ((TransportIpAddress) address).getInetAddress() : null;
    }

    /**
     * Checks if the peer address matches any of the allowed addresses in the policy profile.
     * Supported patterns in allowedAddresses:
     * <ul>
     *     <li>Exact address match (with or without port)</li>
     *     <li>CIDR notation for IPv4: e.g., "192.168.1.0/24". "10.1.0.0/16/161" (with port), "10.1.0.0/16/0" (wildcard port)</li>
     *     <li>CIDR notation for IPv6: e.g., "2001:db8::/32"</li>
     * </ul>
     *
     * @param profile     the policy profile containing allowed addresses
     * @param peerAddress the peer address to check
     * @return {@code true} if the peer address is explicitly allowed, {@code false} otherwise
     */
    private static boolean isExplicitlyAllowedAddress(AgentPolicyProfile profile, Address peerAddress) {
        List<String> allowedAddresses = profile.getAllowedAddresses();
        if ((allowedAddresses == null) || allowedAddresses.isEmpty() || (peerAddress == null)) {
            return false;
        }
        if (allowedAddresses.contains(peerAddress.toString())) {
            return true;
        }
        // Fall back to comparing the IP address only (ignoring the port).
        InetAddress peerInet = toInetAddress(peerAddress);
        if (peerInet == null) {
            return false;
        }
        for (String allowed : allowedAddresses) {
            InetAddress allowedInet = toInetAddress(allowed);
            if (allowedInet != null) {
                if (peerInet.equals(allowedInet)) {
                    return portMatches(peerAddress, allowed, false);
                }
                // Check for CIDR notation / network mask match
                int prefixLength = parseCIDR(allowed);
                if (prefixLength > 0 && matchesNetworkMask(peerInet, allowedInet, prefixLength)) {
                    return portMatches(peerAddress, allowed, true);
                }
            }
        }
        return false;
    }

    private static boolean portMatches(Address peerAddress, String allowedTransportString, boolean cidrMatch) {
        int allowedPort = parsePort(allowedTransportString, cidrMatch);
        return ((allowedPort == 0) ||
                (!(peerAddress instanceof TransportIpAddress)) ||
                allowedPort == ((TransportIpAddress) peerAddress).getPort());
    }

    /**
     * Parses the (optional) transport port from an allowed address entry.
     * <p>
     * Supported entry layouts are {@code ip}, {@code ip/port}, {@code ip/prefix} (CIDR) and
     * {@code ip/prefix/port}. The IP part itself may contain '/'-free IPv6 colons, so only the
     * segments <em>after</em> the first '/' are inspected. Whether the first such segment is a port
     * or a CIDR prefix depends on how the entry matched the peer, hence the {@code cidrMatch} flag.
     *
     * @param allowedTransportString
     *    the allowed address entry.
     * @param cidrMatch
     *    {@code true} if the entry matched the peer as a CIDR network (so its first '/' segment is
     *    the prefix length and the port, if any, follows the second '/'); {@code false} if the entry
     *    matched as an exact address (so its first '/' segment is the port).
     * @return
     *    the configured port, or {@code 0} (wildcard) if none is specified or it cannot be parsed.
     */
    private static int parsePort(String allowedTransportString, boolean cidrMatch) {
        int slashIndex = allowedTransportString.indexOf('/');
        if (slashIndex < 0) {
            return 0;
        }
        String[] suffix = allowedTransportString.substring(slashIndex + 1).split("/");
        // When both a prefix and a port are present (ip/prefix/port), the port is the last segment.
        // With a single suffix segment it is the port for an exact entry, but the CIDR prefix (i.e.
        // no port) for a CIDR entry.
        String portPart;
        if (suffix.length >= 2) {
            portPart = suffix[suffix.length - 1];
        } else if (!cidrMatch && suffix.length == 1) {
            portPart = suffix[0];
        } else {
            portPart = null;
        }
        if ((portPart == null) || portPart.isEmpty()) {
            return 0;
        }
        try {
            return Integer.parseInt(portPart);
        } catch (NumberFormatException e) {
            return 0;
        }
    }

    /**
     * Parses the IP address literal of an allowed address entry into an {@link InetAddress}.
     * <p>
     * Only the literal IP part (everything before the first '/') is considered, so both IPv4
     * ({@code 10.0.0.0/24/161}) and IPv6 ({@code 2001:db8::/32/161}) entries are handled correctly.
     * Host names are deliberately <em>not</em> resolved: doing so would turn an access-control
     * decision into a DNS-dependent (and DNS-spoofable) one and could block the request-processing
     * thread on a name lookup. Non-numeric entries are therefore rejected.
     *
     * @param address
     *    an allowed address entry, optionally with a CIDR prefix and/or port.
     * @return
     *    the parsed {@link InetAddress}, or {@code null} if the entry is empty, contains a host name,
     *    or is not a valid IP literal.
     */
    private static InetAddress toInetAddress(String address) {
        if (address == null) {
            return null;
        }
        // The IP literal is everything before the first '/' (the CIDR/port separator).
        int slashIndex = address.indexOf('/');
        String ipLiteral = ((slashIndex >= 0) ? address.substring(0, slashIndex) : address).trim();
        // Strip the brackets of a bracketed IPv6 literal, e.g. "[2001:db8::1]".
        if (ipLiteral.length() > 1 && ipLiteral.charAt(0) == '[' && ipLiteral.charAt(ipLiteral.length() - 1) == ']') {
            ipLiteral = ipLiteral.substring(1, ipLiteral.length() - 1);
        }
        if (ipLiteral.isEmpty()) {
            return null;
        }
        if (!isNumericIpLiteral(ipLiteral)) {
            if (logger.isDebugEnabled()) {
                logger.debug("Ignoring allowed address entry that is not a numeric IP literal " +
                        "(host names are not resolved): " + address);
            }
            return null;
        }
        try {
            // Safe against DNS resolution: the literal has been validated as numeric, so
            // getByName() parses it locally and never performs a name lookup.
            return InetAddress.getByName(ipLiteral);
        } catch (UnknownHostException e) {
            logger.warn("Failed to parse IP address from allowed entry '" + address + "': " + e.getMessage());
            return null;
        }
    }

    /**
     * Checks whether the given string is a numeric IPv4 or IPv6 address literal (as opposed to a
     * host name). This guard ensures {@link InetAddress#getByName(String)} never triggers a DNS
     * lookup for an allowed address entry.
     *
     * @param literal
     *    the candidate IP literal (without CIDR prefix, port or brackets).
     * @return
     *    {@code true} if {@code literal} is an IPv4 dotted-quad or contains a ':' (IPv6); the IPv6
     *    syntax itself is validated subsequently by {@link InetAddress#getByName(String)}.
     */
    private static boolean isNumericIpLiteral(String literal) {
        // IPv6 literals (including IPv4-mapped forms) contain a colon; host names never do.
        // getByName() rejects a colon-containing string that is not a valid IPv6 literal without
        // performing any name resolution.
        if (literal.indexOf(':') >= 0) {
            return true;
        }
        // Otherwise it must be an IPv4 dotted-quad of four 0..255 decimal octets.
        String[] octets = literal.split("\\.", -1);
        if (octets.length != 4) {
            return false;
        }
        for (String octet : octets) {
            if (octet.isEmpty() || octet.length() > 3) {
                return false;
            }
            for (int i = 0; i < octet.length(); i++) {
                if (!Character.isDigit(octet.charAt(i))) {
                    return false;
                }
            }
            if (Integer.parseInt(octet) > 255) {
                return false;
            }
        }
        return true;
    }

    /**
     * Checks if the peer address matches the allowed address using a network mask (CIDR notation).
     *
     * @param peerAddress    the peer IP address
     * @param allowedAddress the allowed IP address (network address)
     * @param prefixLength   the prefix length (CIDR notation)
     * @return {@code true} if the peer address is within the allowed network, {@code false} otherwise
     */
    private static boolean matchesNetworkMask(InetAddress peerAddress, InetAddress allowedAddress, int prefixLength) {
        if (peerAddress == null || allowedAddress == null) {
            return false;
        }
        // Ensure both addresses are of the same type
        if (!peerAddress.getClass().equals(allowedAddress.getClass())) {
            return false;
        }
        byte[] peerBytes = peerAddress.getAddress();
        byte[] allowedBytes = allowedAddress.getAddress();

        int fullBytes = prefixLength / 8;
        int remainingBits = prefixLength % 8;

        // Compare full bytes
        for (int i = 0; i < fullBytes && i < peerBytes.length; i++) {
            if (peerBytes[i] != allowedBytes[i]) {
                return false;
            }
        }

        // Compare remaining bits
        if (remainingBits > 0 && fullBytes < peerBytes.length) {
            int mask = (0xFF << (8 - remainingBits)) & 0xFF;
            if ((peerBytes[fullBytes] & mask) != (allowedBytes[fullBytes] & mask)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Parses CIDR prefix length from an Address object. The prefix length is encoded
     * in the address string representation after a "/" character.
     *
     * @param address the address to parse
     * @return the prefix length, or -1 if no CIDR notation is found
     */
    private static int parseCIDR(String address) {
        if (address == null) {
            return -1;
        }
        int slashIndex = address.indexOf('/');
        if (slashIndex < 0) {
            return -1;
        }
        try {
            // Extract the part after the first '/' and before any second '/' (port separator)
            int endIndex = address.indexOf('/', slashIndex + 1);
            String cidrPart = (endIndex > 0) ?
                    address.substring(slashIndex + 1, endIndex) :
                    address.substring(slashIndex + 1);
            return Integer.parseInt(cidrPart);
        } catch (NumberFormatException e) {
            return -1;
        }
    }

    /*-------------------------------------------------------------------------------------------------
     * Policy evaluation - file system
     *-----------------------------------------------------------------------------------------------*/

    /**
     * Evaluates whether the supplied file operation is permitted by the {@link AgentPolicyProfile}.
     *
     * @param event
     *    the file audit event to evaluate.
     * @return
     *    {@code true} if the operation is permitted by the policy profile, {@code false} otherwise.
     */
    protected boolean isFileOperationAllowed(FileAuditEvent event) {
        AgentPolicyProfile profile = agentPolicyProfile;
        if (profile == null) {
            return true;
        }
        AgentPolicyProfile.FileAccess fileAccess = profile.getFileAccess();
        if (fileAccess == null) {
            return true;
        }
        switch (fileAccess) {
            case NO_ACCESS:
                return false;
            case ALL_DIRECTORY_ACCESS:
                return true;
            case WORKING_DIRECTORY_READONLY:
                return event.getOperation() == FileAuditEvent.FileOperation.READ &&
                        isExplicitlyAllowedFile(profile, event.getFile(), true) ||
                        isWithinDirectory(getWorkingDirectory(), event.getFile());
            case WORKING_DIRECTORY_READWRITE:
                return isExplicitlyAllowedFile(profile, event.getFile(), true) ||
                        isWithinDirectory(getWorkingDirectory(), event.getFile());
            case LIMITED_DIRECTORY_ACCESS:
                return isExplicitlyAllowedFile(profile, event.getFile(), false) ||
                        isWithinAllowedDirectories(profile, event.getFile());
            default:
                return false;
        }
    }

    private static boolean isExplicitlyAllowedFile(AgentPolicyProfile profile, File file,
                                                   boolean mustBeWorkingDir) {
        List<String> allowedFiles = profile.getAllowedFiles();
        if ((allowedFiles == null) || (file == null)) {
            return false;
        }
        String canonicalFile = canonical(file);
        for (String allowed : allowedFiles) {
            if ((allowed != null) && canonical(new File(allowed)).equals(canonicalFile)) {
                return !mustBeWorkingDir || isWithinDirectory(getWorkingDirectory(), file);
            }
        }
        return false;
    }

    private static boolean isWithinAllowedDirectories(AgentPolicyProfile profile, File file) {
        List<String> allowedDirectories = profile.getAllowedDirectories();
        if (allowedDirectories == null) {
            return false;
        }
        for (String directory : allowedDirectories) {
            if (isWithinDirectory(new File(directory), file)) {
                return true;
            }
        }
        return false;
    }

    private static boolean isWithinDirectory(File directory, File file) {
        if ((directory == null) || (file == null)) {
            return false;
        }
        String dir = canonical(directory);
        String target = canonical(file);
        return target.equals(dir) || target.startsWith(dir + File.separator);
    }

    private static String canonical(File file) {
        try {
            return file.getCanonicalPath();
        } catch (IOException ex) {
            return file.getAbsolutePath();
        }
    }

    private static File getWorkingDirectory() {
        return new File(System.getProperty("user.dir", "."));
    }

    /*-------------------------------------------------------------------------------------------------
     * Policy evaluation - managed objects
     *-----------------------------------------------------------------------------------------------*/

    /**
     * Evaluates whether the managed object access described by the supplied lookup event conforms to the
     * {@link AgentPolicyProfile}. The result is used for auditing only (logging), since managed object
     * access is enforced by VACM.
     *
     * @param event
     *    the managed object server lookup event to evaluate.
     * @return
     *    {@code true} if the access conforms to the policy profile, {@code false} otherwise.
     */
    protected boolean isManagedObjectAccessAllowed(MOServerLookupEvent event) {
        AgentPolicyProfile profile = agentPolicyProfile;
        if (profile == null) {
            return true;
        }
        MOQuery query = event.getQuery();
        // Minimum security level check (if the request security level is known).
        int requestSecurityLevel = getRequestSecurityLevel(query);
        if (requestSecurityLevel > 0) {
            SecurityLevel minSecurityLevel = profile.getMinSecurityLevel();
            if ((minSecurityLevel != null) && (requestSecurityLevel < minSecurityLevel.getSnmpValue())) {
                return false;
            }
        }
        AgentPolicyProfile.ManagedObjectAccess requestedAccess = determineRequestedAccess(query);
        OID oid = getQueryOID(query);
        Map<String, AgentPolicyProfile.ManagedObjectAccess> allowedObjects = profile.getAllowedObjects();
        if ((allowedObjects != null) && (!allowedObjects.isEmpty())) {
            AgentPolicyProfile.ManagedObjectAccess grantedForObject = findGrantedAccess(allowedObjects, oid);
            if (grantedForObject == null) {
                return false;
            }
            return isAccessGranted(grantedForObject, requestedAccess);
        }
        return isAccessGranted(profile.getManagedObjectAccess(), requestedAccess);
    }

    private static AgentPolicyProfile.ManagedObjectAccess determineRequestedAccess(MOQuery query) {
        if (query == null) {
            return AgentPolicyProfile.ManagedObjectAccess.READ;
        }
        return query.isWriteAccessQuery() ?
                AgentPolicyProfile.ManagedObjectAccess.READ_WRITE : AgentPolicyProfile.ManagedObjectAccess.READ;
    }

    /**
     * Checks whether the granted access level covers the requested access. The access enum is treated as
     * an ordered capability lattice {@code READ < WRITE < CREATE < DELETE} where a higher capability
     * includes all lower ones (e.g. {@code WRITE} access also allows {@code READ}).
     */
    private static boolean isAccessGranted(AgentPolicyProfile.ManagedObjectAccess granted,
                                           AgentPolicyProfile.ManagedObjectAccess requested) {
        if (granted == null) {
            return false;
        }
        if (requested == null) {
            return true;
        }
        return granted.ordinal() >= requested.ordinal();
    }

    private static AgentPolicyProfile.ManagedObjectAccess findGrantedAccess(
            Map<String, AgentPolicyProfile.ManagedObjectAccess> allowedObjects, OID oid) {
        if (oid == null) {
            return null;
        }
        AgentPolicyProfile.ManagedObjectAccess granted = null;
        int bestPrefixLength = -1;
        for (Map.Entry<String, AgentPolicyProfile.ManagedObjectAccess> entry : allowedObjects.entrySet()) {
            OID prefix = new OID(entry.getKey());
            if (oid.startsWith(prefix) && (prefix.size() > bestPrefixLength)) {
                bestPrefixLength = prefix.size();
                granted = entry.getValue();
            }
        }
        return granted;
    }

    private static OID getQueryOID(MOQuery query) {
        if (query == null) {
            return null;
        }
        MOContextScope scope = query.getScope();
        return (scope == null) ? null : scope.getLowerBound();
    }

    private static int getRequestSecurityLevel(MOQuery query) {
        if (query instanceof MOQueryWithSource) {
            Object source = ((MOQueryWithSource) query).getSource();
            if (source instanceof Request) {
                return ((Request<?, ?, ?>) source).getSecurityLevel();
            }
        }
        return -1;
    }

    private static String describe(MOServerLookupEvent event) {
        MOQuery query = event.getQuery();
        OID oid = getQueryOID(query);
        String access = (query != null && query.isWriteAccessQuery()) ? "WRITE" : "READ";
        if (query instanceof CommandProcessor.VACMQuery) {
            CommandProcessor.VACMQuery vacmQuery = (CommandProcessor.VACMQuery) query;
            return "intendedUse=" + event.getIntendedUse() + ", access=" + access + ", oid=" + oid +
                    ", query=" + query + ", snmpRequest=" + vacmQuery.getSource();
        }
        return "intendedUse=" + event.getIntendedUse() + ", access=" + access + ", oid=" + oid +
                ", query=" + query;
    }

    /*-------------------------------------------------------------------------------------------------
     * Auditing / logging
     *-----------------------------------------------------------------------------------------------*/

    /**
     * Logs the outcome of a policy decision according to the configured {@link AgentAuditLevel} and
     * returns whether the audited operation is permitted.
     *
     * @param permittedByPolicy
     *    {@code true} if the operation conforms to the policy profile.
     * @param enforceable
     *    {@code true} if the calling listener method can actually deny the operation (transport and
     *    file audits), {@code false} for observational audits (managed object lookups).
     * @param category
     *    a short category label for the audit record (e.g. {@code "TRANSPORT"}).
     * @param eventDescription
     *    the audited event (its {@code toString()} is included in the audit record).
     * @return
     *    {@code true} if the operation is permitted (always {@code true} unless the level is
     *    {@link AgentAuditLevel#RESTRICT}, the operation violates the policy and is {@code enforceable}).
     */
    protected boolean logDecision(boolean permittedByPolicy, boolean enforceable,
                                  String category, Object eventDescription) {
        AgentAuditLevel level = auditLevel;
        if (level == AgentAuditLevel.OFF) {
            return true;
        }
        boolean violation = !permittedByPolicy;
        boolean denied = violation && enforceable && (level == AgentAuditLevel.RESTRICT);
        String status;
        if (!violation) {
            status = "permitted";
        } else if (denied) {
            status = "DENIED";
        } else if (enforceable) {
            status = "policy violation (allowed by audit level " + level + ")";
        } else {
            status = "policy violation (enforcement delegated to VACM)";
        }
        boolean asWarning = violation && ((level == AgentAuditLevel.WARN) || (level == AgentAuditLevel.RESTRICT));
        String record = "agent audit [" + category + "] " + status + ": " + eventDescription;
        if (asWarning) {
            logger.warn(record);
        } else {
            logger.info(record);
        }
        writeToAuditStream(asWarning, record);
        return !denied;
    }

    private void writeToAuditStream(boolean warning, String record) {
        PrintStream out = this.auditOut;
        if (out != null) {
            out.println(LocalDateTime.now().format(TS_FORMAT) + " " + (warning ? "WARN " : "INFO ") + record);
        }
    }
}
