Ctrl K
ComunidadGuías…

internal/adminapi/policy_handler.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package adminapi

import (
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"time"

	"github.com/yeomyeonggeori/blueclaw/internal/identity"
	"github.com/yeomyeonggeori/blueclaw/internal/policy"
)

type PolicyHandler struct {
	PolicyPath                   string
	PolicyLoader                 policy.PolicyLoader
	PolicySaver                  policy.PolicySaver
	PolicyWatcher                *policy.PolicyWatcher
	Validator                    policy.PolicyValidator
	AuditHandler                 *AuditHandler
	PersonReferenceCanonicalizer PersonReferenceCanonicalizer
	PlatformAccountLinker        PlatformAccountLinker
	OnPolicyReload               func(policy.PolicyDocument)
}

type PersonReferenceCanonicalizer interface {
	CanonicalizePersonReferences(legacyPersonID string, personID string) error
}

type PlatformAccountLinker interface {
	RememberPlatformAccount(platformAccountIdentity identity.PlatformAccountIdentity)
}

type invitePersonRequest struct {
	PersonID    string   `json:"personID"`
	Email       string   `json:"email"`
	DisplayName string   `json:"displayName"`
	IsAdmin     bool     `json:"isAdmin"`
	Circles     []string `json:"circles"`
}

type canonicalizePersonReferencesRequest struct {
	LegacyPersonID string `json:"legacyPersonID"`
	PersonID       string `json:"personID"`
}

func (policyHandler PolicyHandler) HandleGetPolicy(responseWriter http.ResponseWriter, request *http.Request) {
	policyDocument, errorValue := policyHandler.PolicyLoader.LoadPolicyDocument(policyHandler.PolicyPath)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(responseWriter, http.StatusOK, policy.CanonicalizePolicyDocument(policyDocument))
}

func (policyHandler PolicyHandler) HandleValidatePolicy(responseWriter http.ResponseWriter, request *http.Request) {
	var policyDocument policy.PolicyDocument
	errorValue := json.NewDecoder(request.Body).Decode(&policyDocument)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}

	policyDocument = policy.CanonicalizePolicyDocument(policyDocument)
	policyDocument = policy.CanonicalizePolicyDocument(policyDocument)
	errorValue = policyHandler.Validator.ValidatePolicyDocument(policyDocument)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}

	writeJSON(responseWriter, http.StatusOK, map[string]bool{"isValid": true})
}

func (policyHandler PolicyHandler) HandleSavePolicy(responseWriter http.ResponseWriter, request *http.Request) {
	var policyDocument policy.PolicyDocument
	errorValue := json.NewDecoder(request.Body).Decode(&policyDocument)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}

	errorValue = policyHandler.Validator.ValidatePolicyDocument(policyDocument)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}

	backupPath, _ := policyHandler.PolicySaver.BackupPolicyDocument(policyHandler.PolicyPath)
	errorValue = policyHandler.PolicySaver.SavePolicyDocumentAtomically(policyHandler.PolicyPath, policyDocument)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}

	policyHandler.PolicyWatcher.ReloadPolicyDocument(policyDocument)
	policyHandler.notifyPolicyReload(policyDocument)
	policyHandler.AuditHandler.RecordPolicySave(backupPath)
	writeJSON(responseWriter, http.StatusOK, map[string]string{"backupPath": backupPath})
}

// loadPolicySettled reads the roster, and reads it again if the first attempt caught the
// file mid-write. The host rewrites this file in place, which leaves a window where a reader
// sees half of it. Refusing on that window would leave the agent on its previous roster
// until somebody asked again.
func (policyHandler PolicyHandler) loadPolicySettled() (policy.PolicyDocument, error) {
	policyDocument, errorValue := policyHandler.PolicyLoader.LoadPolicyDocument(policyHandler.PolicyPath)
	if errorValue == nil {
		return policyDocument, nil
	}
	time.Sleep(policyWriteSettleDelay)
	return policyHandler.PolicyLoader.LoadPolicyDocument(policyHandler.PolicyPath)
}

// policyWriteSettleDelay is how long a torn read waits before looking again. One rewrite of
// a small file finishes far inside it, and a reload is never on a hot path.
const policyWriteSettleDelay = 150 * time.Millisecond

func (policyHandler PolicyHandler) HandleReloadPolicy(responseWriter http.ResponseWriter, request *http.Request) {
	policyDocument, errorValue := policyHandler.loadPolicySettled()
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}

	policyHandler.PolicyWatcher.ReloadPolicyDocument(policyDocument)
	policyHandler.notifyPolicyReload(policyDocument)
	writeJSON(responseWriter, http.StatusOK, map[string]int{"people": len(policyDocument.People)})
}

func (policyHandler PolicyHandler) HandleInvitePerson(responseWriter http.ResponseWriter, request *http.Request) {
	var inviteRequest invitePersonRequest
	errorValue := json.NewDecoder(request.Body).Decode(&inviteRequest)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}

	policyDocument, personPolicy, backupPath, errorValue := policyHandler.invitePerson(inviteRequest)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), statusCodeForPolicyMutationError(errorValue))
		return
	}

	policyHandler.PolicyWatcher.ReloadPolicyDocument(policyDocument)
	policyHandler.notifyPolicyReload(policyDocument)
	policyHandler.linkInvitedPersonMattermostAccount(personPolicy)
	if backupPath != "" {
		policyHandler.AuditHandler.RecordPolicySave(backupPath)
	}
	writeJSON(responseWriter, http.StatusOK, personPolicy)
}

// An invited workspace person carries their Mattermost user ID as the personID, so
// link that platform account immediately. Without this the person resolves by name
// but stays unlinked until Blueclaw happens to ingest a message from them, which
// would make a freshly invited member unreachable by direct message.
func (policyHandler PolicyHandler) linkInvitedPersonMattermostAccount(personPolicy policy.PersonPolicy) {
	if policyHandler.PlatformAccountLinker == nil {
		return
	}
	externalUserID := strings.TrimSpace(personPolicy.PersonID)
	email := firstPersonEmail(personPolicy)
	if externalUserID == "" || email == "" {
		return
	}
	policyHandler.PlatformAccountLinker.RememberPlatformAccount(identity.PlatformAccountIdentity{
		Platform:       "mattermost",
		ExternalUserID: externalUserID,
		Email:          email,
		DisplayName:    strings.TrimSpace(personPolicy.DisplayName),
		PersonID:       externalUserID,
	})
}

func firstPersonEmail(personPolicy policy.PersonPolicy) string {
	for _, email := range personPolicy.Emails {
		trimmedEmail := strings.TrimSpace(email)
		if trimmedEmail != "" {
			return trimmedEmail
		}
	}
	return ""
}

func (policyHandler PolicyHandler) HandleRemovePerson(responseWriter http.ResponseWriter, request *http.Request) {
	email := normalizeEmail(request.URL.Query().Get("email"))
	personID := strings.TrimSpace(request.URL.Query().Get("personID"))
	policyDocument, backupPath, errorValue := policyHandler.removePerson(email, personID)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), statusCodeForPolicyMutationError(errorValue))
		return
	}

	policyHandler.PolicyWatcher.ReloadPolicyDocument(policyDocument)
	policyHandler.notifyPolicyReload(policyDocument)
	if backupPath != "" {
		policyHandler.AuditHandler.RecordPolicySave(backupPath)
	}
	writeJSON(responseWriter, http.StatusOK, map[string]bool{"isRemoved": true})
}

func (policyHandler PolicyHandler) HandleCanonicalizePersonReferences(responseWriter http.ResponseWriter, request *http.Request) {
	if policyHandler.PersonReferenceCanonicalizer == nil {
		http.Error(responseWriter, "person reference canonicalizer is not configured", http.StatusServiceUnavailable)
		return
	}
	var canonicalizeRequest canonicalizePersonReferencesRequest
	errorValue := json.NewDecoder(request.Body).Decode(&canonicalizeRequest)
	if errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest)
		return
	}
	legacyPersonID := strings.TrimSpace(canonicalizeRequest.LegacyPersonID)
	personID := strings.TrimSpace(canonicalizeRequest.PersonID)
	if legacyPersonID == "" || personID == "" {
		http.Error(responseWriter, "legacyPersonID and personID are required", http.StatusBadRequest)
		return
	}
	if legacyPersonID == personID {
		writeJSON(responseWriter, http.StatusOK, map[string]bool{"isCanonicalized": false})
		return
	}
	if errorValue := policyHandler.PersonReferenceCanonicalizer.CanonicalizePersonReferences(legacyPersonID, personID); errorValue != nil {
		http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError)
		return
	}
	writeJSON(responseWriter, http.StatusOK, map[string]bool{"isCanonicalized": true})
}

func (policyHandler PolicyHandler) notifyPolicyReload(policyDocument policy.PolicyDocument) {
	if policyHandler.OnPolicyReload != nil {
		policyHandler.OnPolicyReload(policyDocument)
	}
}

func (policyHandler PolicyHandler) invitePerson(inviteRequest invitePersonRequest) (policy.PolicyDocument, policy.PersonPolicy, string, error) {
	personID := strings.TrimSpace(inviteRequest.PersonID)
	if personID == "" {
		return policy.PolicyDocument{}, policy.PersonPolicy{}, "", errors.New("personID is required")
	}
	email := normalizeEmail(inviteRequest.Email)
	if email == "" {
		return policy.PolicyDocument{}, policy.PersonPolicy{}, "", errors.New("email is required")
	}

	policyDocument, errorValue := policyHandler.PolicyLoader.LoadPolicyDocument(policyHandler.PolicyPath)
	if errorValue != nil {
		return policy.PolicyDocument{}, policy.PersonPolicy{}, "", errorValue
	}

	for index, personPolicy := range policyDocument.People {
		if personPolicy.PersonID == personID && !personHasEmail(personPolicy, email) {
			return policy.PolicyDocument{}, policy.PersonPolicy{}, "", errors.New("personID already belongs to another person")
		}
		if personHasEmail(personPolicy, email) {
			if personPolicy.PersonID == personID {
				return policyDocument, personPolicy, "", nil
			}
			personPolicy.PersonID = personID
			policyDocument.People[index] = personPolicy
			backupPath, errorValue := policyHandler.savePolicyDocument(policyDocument)
			if errorValue != nil {
				return policy.PolicyDocument{}, policy.PersonPolicy{}, "", errorValue
			}
			return policyDocument, personPolicy, backupPath, nil
		}
	}

	personPolicy := createInvitedPersonPolicy(inviteRequest, email)
	policyDocument.People = append(policyDocument.People, personPolicy)
	backupPath, errorValue := policyHandler.savePolicyDocument(policyDocument)
	if errorValue != nil {
		return policy.PolicyDocument{}, policy.PersonPolicy{}, "", errorValue
	}

	return policyDocument, personPolicy, backupPath, nil
}

func (policyHandler PolicyHandler) removePerson(email string, personID string) (policy.PolicyDocument, string, error) {
	if email == "" && personID == "" {
		return policy.PolicyDocument{}, "", errors.New("email or personID is required")
	}

	policyDocument, errorValue := policyHandler.PolicyLoader.LoadPolicyDocument(policyHandler.PolicyPath)
	if errorValue != nil {
		return policy.PolicyDocument{}, "", errorValue
	}

	filteredPeople := make([]policy.PersonPolicy, 0, len(policyDocument.People))
	isRemoved := false
	for _, personPolicy := range policyDocument.People {
		if shouldRemovePerson(personPolicy, email, personID) {
			if personPolicy.IsAdmin {
				return policy.PolicyDocument{}, "", errors.New("cannot remove admin person")
			}
			isRemoved = true
			continue
		}
		filteredPeople = append(filteredPeople, personPolicy)
	}
	if !isRemoved {
		return policy.PolicyDocument{}, "", errors.New("person not found")
	}

	policyDocument.People = filteredPeople
	backupPath, errorValue := policyHandler.savePolicyDocument(policyDocument)
	if errorValue != nil {
		return policy.PolicyDocument{}, "", errorValue
	}

	return policyDocument, backupPath, nil
}

func (policyHandler PolicyHandler) savePolicyDocument(policyDocument policy.PolicyDocument) (string, error) {
	policyDocument = policy.CanonicalizePolicyDocument(policyDocument)
	errorValue := policyHandler.Validator.ValidatePolicyDocument(policyDocument)
	if errorValue != nil {
		return "", errorValue
	}

	backupPath, _ := policyHandler.PolicySaver.BackupPolicyDocument(policyHandler.PolicyPath)
	errorValue = policyHandler.PolicySaver.SavePolicyDocumentAtomically(policyHandler.PolicyPath, policyDocument)
	if errorValue != nil {
		return "", errorValue
	}

	return backupPath, nil
}

func createInvitedPersonPolicy(inviteRequest invitePersonRequest, email string) policy.PersonPolicy {
	circles := normalizeCircles(append([]string{policy.MemberCircleID}, inviteRequest.Circles...))
	if inviteRequest.IsAdmin {
		circles = normalizeCircles(append(circles, policy.AdminCircleID))
	}

	return policy.PersonPolicy{
		PersonID:    strings.TrimSpace(inviteRequest.PersonID),
		DisplayName: displayNameForInvite(inviteRequest.DisplayName, email),
		Emails:      []string{email},
		Circles:     circles,
		IsAdmin:     inviteRequest.IsAdmin,
	}
}

func normalizeCircles(values []string) []string {
	seenValue := map[string]bool{}
	circles := []string{}
	for _, value := range values {
		trimmedValue := strings.ToLower(strings.TrimSpace(value))
		if trimmedValue == "" || seenValue[trimmedValue] {
			continue
		}
		seenValue[trimmedValue] = true
		circles = append(circles, trimmedValue)
	}
	return circles
}

func displayNameForInvite(displayName string, email string) string {
	trimmedDisplayName := strings.TrimSpace(displayName)
	if trimmedDisplayName != "" {
		return trimmedDisplayName
	}

	localPart, _, isEmail := strings.Cut(email, "@")
	if isEmail && localPart != "" {
		return localPart
	}

	return email
}

func personHasEmail(personPolicy policy.PersonPolicy, email string) bool {
	for _, personEmail := range personPolicy.Emails {
		if normalizeEmail(personEmail) == email {
			return true
		}
	}
	return false
}

func shouldRemovePerson(personPolicy policy.PersonPolicy, email string, personID string) bool {
	if personID != "" && personPolicy.PersonID == personID {
		return true
	}
	if email == "" {
		return false
	}
	return personHasEmail(personPolicy, email)
}

func normalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

func statusCodeForPolicyMutationError(errorValue error) int {
	switch errorValue.Error() {
	case "personID is required", "email is required", "email or personID is required", "cannot remove admin person", "personID already belongs to another person":
		return http.StatusBadRequest
	case "person not found":
		return http.StatusNotFound
	default:
		return http.StatusInternalServerError
	}
}

func writeJSON(responseWriter http.ResponseWriter, statusCode int, value any) {
	responseWriter.Header().Set("Content-Type", "application/json")
	responseWriter.WriteHeader(statusCode)
	_ = json.NewEncoder(responseWriter).Encode(value)
}