internal/adminapi/policy_handler.go
Rama main · réplica de lectura del repositorio original.
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)
}