/*_############################################################################
  _##
  _##  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.MOContextScope;
import org.snmp4j.agent.MOQuery;
import org.snmp4j.agent.MOQueryWithSource;
import org.snmp4j.agent.MOServerLookupEvent;
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.Address;
import org.snmp4j.smi.IpAddress;
import org.snmp4j.smi.OID;
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.InetAddress;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
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_ONLY, 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);
        switch (networkAccess) {
            case LOCAL_NETWORK:
                return local;
            case REMOTE_NETWORK_LIMITED:
                return local || isExplicitlyAllowedAddress(profile, peerAddress);
            default:
                return true;
        }
    }

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

    private static boolean isExplicitlyAllowedAddress(AgentPolicyProfile profile, Address peerAddress) {
        Set<Address> allowedAddresses = profile.getAllowedAddresses();
        if ((allowedAddresses == null) || allowedAddresses.isEmpty() || (peerAddress == null)) {
            return false;
        }
        if (allowedAddresses.contains(peerAddress)) {
            return true;
        }
        // Fall back to comparing the IP address only (ignoring the port).
        InetAddress peerInet = toInetAddress(peerAddress);
        if (peerInet == null) {
            return false;
        }
        for (Address allowed : allowedAddresses) {
            if (peerInet.equals(toInetAddress(allowed))) {
                return true;
            }
        }
        return false;
    }

    private static InetAddress toInetAddress(Address address) {
        if (address instanceof IpAddress) {
            return ((IpAddress) address).getInetAddress();
        }
        return null;
    }

    /*-------------------------------------------------------------------------------------------------
     * 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) {
        File[] allowedFiles = profile.getAllowedFiles();
        if ((allowedFiles == null) || (file == null)) {
            return false;
        }
        String canonicalFile = canonical(file);
        for (File allowed : allowedFiles) {
            if ((allowed != null) && canonical(allowed).equals(canonicalFile)) {
                return !mustBeWorkingDir || isWithinDirectory(getWorkingDirectory(), file);
            }
        }
        return false;
    }

    private static boolean isWithinAllowedDirectories(AgentPolicyProfile profile, File file) {
        File[] allowedDirectories = profile.getAllowedDirectories();
        if (allowedDirectories == null) {
            return false;
        }
        for (File directory : allowedDirectories) {
            if (isWithinDirectory(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<OID, 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<OID, AgentPolicyProfile.ManagedObjectAccess> allowedObjects, OID oid) {
        if (oid == null) {
            return null;
        }
        AgentPolicyProfile.ManagedObjectAccess granted = null;
        int bestPrefixLength = -1;
        for (Map.Entry<OID, AgentPolicyProfile.ManagedObjectAccess> entry : allowedObjects.entrySet()) {
            OID prefix = entry.getKey();
            if ((prefix != null) && 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";
        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);
        }
    }
}
