// Package privilege is the policy manager for privilege escalation entry types.
//
// This manager allows (and denies) privilege escalation on the client by configuring sudo and polkit
// files. In order to do that, it modifies 2 files (one for sudo and one for polkit) and their default
// locations are, respectively:
//   - /etc/sudoers.d/99-adsys-privilege-enforcement
//   - /etc/polkit-1/localauthority.conf.d/99-adsys-privilege-enforcement
//
// This is an all or nothing type of policy and, therefore, requires a lot of attention during setup.
// If the policy is setup improperly, users could end up with too much (or too little) privilege,
// which could compromise the safety and/or usability of the machine until the policy gets updated.
// If the policy is set without any value (or it's disabled) the files are removed and the default
// privilege configuration is restored.
// Should the manager fail to create the files with the requested values, it will return an error and
// authentication will be prevented.
package privilege

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/leonelquinteros/gotext"
	"github.com/ubuntu/adsys/internal/consts"
	log "github.com/ubuntu/adsys/internal/grpc/logstreamer"
	"github.com/ubuntu/adsys/internal/policies/entry"
	"github.com/ubuntu/decorate"
	"gopkg.in/ini.v1"
)

/*
	Notes:
	privilege allows and deny privilege escalation on the client.

	It does so in modifying policykit and sudo files to override default distribution rules.

	This is all or nothing, similarly to the sudo policy files in most default distribution setup.

	We are modifying 2 files:
	- one for sudo, named 99-adsys-privilege-enforcement in sudoers.d
	- one under 99-adsys-privilege-enforcement.conf for policykit

	Both are installed under respective /etc directories.
*/

const adsysBaseConfName = "99-adsys-privilege-enforcement"

// Manager prevents running multiple privilege update process in parallel while parsing policy in ApplyPolicy.
type Manager struct {
	sudoersDir   string
	policyKitDir string
}

// NewWithDirs creates a manager with a specific root directory.
func NewWithDirs(sudoersDir, policyKitDir string) *Manager {
	return &Manager{
		sudoersDir:   sudoersDir,
		policyKitDir: policyKitDir,
	}
}

// ApplyPolicy generates a privilege policy based on a list of entries.
func (m *Manager) ApplyPolicy(ctx context.Context, objectName string, isComputer bool, entries []entry.Entry) (err error) {
	defer decorate.OnError(&err, gotext.Get("can't apply privilege policy to %s", objectName))

	// We only have privilege escalation on computers.
	if !isComputer {
		return nil
	}

	sudoersDir := m.sudoersDir
	if sudoersDir == "" {
		sudoersDir = consts.DefaultSudoersDir
	}
	policyKitDir := m.policyKitDir
	if policyKitDir == "" {
		policyKitDir = consts.DefaultPolicyKitDir
	}
	sudoersConf := filepath.Join(sudoersDir, adsysBaseConfName)
	policyKitConf := filepath.Join(policyKitDir, "localauthority.conf.d", adsysBaseConfName+".conf")

	log.Debugf(ctx, "Applying privilege policy to %s", objectName)

	// We don’t create empty files if there is no entries. Still remove any previous version.
	if len(entries) == 0 {
		if err := os.Remove(sudoersConf); err != nil && !errors.Is(err, fs.ErrNotExist) {
			return err
		}
		if err := os.Remove(policyKitConf); err != nil && !errors.Is(err, fs.ErrNotExist) {
			return err
		}
		return nil
	}

	// Create our temp files and parent directories
	// nolint:gosec // G301 match distribution permission
	if err := os.MkdirAll(filepath.Dir(sudoersConf), 0755); err != nil {
		return err
	}
	// nolint:gosec // G302 match distribution permission
	sudoersF, err := os.OpenFile(sudoersConf+".new", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0440)
	if err != nil {
		return err
	}
	defer sudoersF.Close()
	// nolint:gosec // G301 match distribution permission
	if err := os.MkdirAll(filepath.Dir(policyKitConf), 0755); err != nil {
		return err
	}
	// nolint:gosec // G302 match distribution permission
	policyKitConfF, err := os.OpenFile(policyKitConf+".new", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
	if err != nil {
		return err
	}
	defer policyKitConfF.Close()

	systemPolkitAdmins, err := getSystemPolkitAdminIdentities(ctx, policyKitDir)
	if err != nil {
		return err
	}

	// Parse our rules and write to temp files
	var headerWritten bool
	header := `# This file is managed by adsys.
# Do not edit this file manually.
# Any changes will be overwritten.

`

	allowLocalAdmins := true
	var polkitAdditionalUsersGroups []string

	for _, entry := range entries {
		var contentSudo string

		if !headerWritten {
			contentSudo = header
		}

		switch entry.Key {
		case "allow-local-admins":
			allowLocalAdmins = !entry.Disabled
			if allowLocalAdmins {
				continue
			}
			contentSudo += "%admin	ALL=(ALL) !ALL\n"
			contentSudo += "%sudo	ALL=(ALL:ALL) !ALL\n"
		case "client-admins":
			if entry.Disabled {
				continue
			}

			var polkitElem []string
			for _, e := range splitAndNormalizeUsersAndGroups(ctx, entry.Value) {
				contentSudo += fmt.Sprintf("\"%s\"	ALL=(ALL:ALL) ALL\n", e)
				polkitID := fmt.Sprintf("unix-user:%s", e)
				if strings.HasPrefix(e, "%") {
					polkitID = fmt.Sprintf("unix-group:%s", strings.TrimPrefix(e, "%"))
				}
				polkitElem = append(polkitElem, polkitID)
			}
			if len(polkitElem) < 1 {
				continue
			}
			polkitAdditionalUsersGroups = polkitElem
		}

		// Write to our files
		if _, err := sudoersF.WriteString(contentSudo + "\n"); err != nil {
			return err
		}
		headerWritten = true
	}
	// PolicyKitConf files depends on multiple keys, so we need to write it at the end
	if !allowLocalAdmins || polkitAdditionalUsersGroups != nil {
		users := strings.Join(polkitAdditionalUsersGroups, ";")
		// We need to set system local admin here as we override the key from the previous file
		// otherwise, they will be disabled.
		if allowLocalAdmins {
			if systemPolkitAdmins != "" {
				systemPolkitAdmins += ";"
			}
			users = systemPolkitAdmins + users
		}

		if _, err := policyKitConfF.WriteString(fmt.Sprintf("%s[Configuration]\nAdminIdentities=%s", header, users) + "\n"); err != nil {
			return err
		}
	}

	// Move temp files to their final destination
	if err := os.Rename(sudoersConf+".new", sudoersConf); err != nil {
		return err
	}
	if err := os.Rename(policyKitConf+".new", policyKitConf); err != nil {
		return err
	}

	return nil
}

// splitAndNormalizeUsersAndGroups allow splitting on lines and ,.
// We remove any invalid characters and empty elements.
// All will have the form of user@domain.
func splitAndNormalizeUsersAndGroups(ctx context.Context, v string) []string {
	var elems []string
	elems = append(elems, strings.Split(v, "\n")...)
	v = strings.Join(elems, ",")
	elems = nil
	for _, e := range strings.Split(v, ",") {
		initialValue := e
		// Invalid chars in Windows user names: '/[]:|<>+=;,?*%"
		isgroup := strings.HasPrefix(e, "%")
		for _, c := range []string{"/", "[", "]", ":", "|", "<", ">", "=", ";", "?", "*", "%"} {
			e = strings.ReplaceAll(e, c, "")
		}
		if isgroup {
			e = "%" + e
		}

		// domain\user becomes user@domain
		ud := strings.SplitN(e, `\`, 2)
		if len(ud) == 2 {
			e = fmt.Sprintf("%s@%s", ud[1], ud[0])
			e = strings.ReplaceAll(e, `\`, "")
		}

		e = strings.TrimSpace(e)
		if e == "" {
			continue
		}
		if e != initialValue {
			log.Warningf(ctx, "Changed user or group %q to %q: Invalid characters or domain\\user format", initialValue, e)
		}
		elems = append(elems, e)
	}

	return elems
}

// getSystemPolkitAdminIdentities returns the list of configured system polkit admins as a string.
// It lists /etc/polkit-1/localauthority.conf.d and take the highest file in ascii order to match
// from the [configuration] section AdminIdentities value.
func getSystemPolkitAdminIdentities(ctx context.Context, policyKitDir string) (adminIdentities string, err error) {
	defer decorate.OnError(&err, gotext.Get("can't get existing system polkit administrators in %s", policyKitDir))

	polkitConfFiles, err := filepath.Glob(filepath.Join(policyKitDir, "localauthority.conf.d", "*.conf"))
	if err != nil {
		return "", err
	}
	sort.Strings(polkitConfFiles)
	for _, p := range polkitConfFiles {
		fi, err := os.Stat(p)
		if err != nil {
			return "", err
		}
		if fi.IsDir() {
			log.Warning(ctx, gotext.Get("%s is a directory. Ignoring.", p))
			continue
		}

		// Ignore ourself
		if filepath.Base(p) == adsysBaseConfName+".conf" {
			continue
		}

		cfg, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, p)
		if err != nil {
			return "", err
		}

		adminIdentities = cfg.Section("Configuration").Key("AdminIdentities").String()
	}

	return adminIdentities, nil
}
