package msgraph import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "sync" "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" "github.com/Kobargo/UKG-Custodian/logger" ) func InitGraphClient(creds GraphClientCredentials) *GraphApi { log := logger.Logger log.Debug().Discard().Msgf("Initializing Microsoft Graph API client with credentials: %s", creds) if creds.ClientSecret == "" { env_secret := os.Getenv("client_secret") if env_secret == "" { log.Error().Msgf("graphapi.InitGraphClient: Failed to obtain a valid secret for credentials. Reading: \"%s\"", env_secret) } creds.ClientSecret = env_secret } generatedCredential, err := confidential.NewCredFromSecret(creds.ClientSecret) if err != nil { log.Err(err).Msgf("main: could not create credential: %s", err) } confidentialClient, err := confidential.New(fmt.Sprintf("https://login.microsoftonline.com/%s", creds.TenantId), creds.ClientId, generatedCredential) if err != nil { log.Err(err).Msgf("graphapi.InitGraphClient: Failed to create confidential client: %s", err) } scopes := []string{".default"} result, err := confidentialClient.AcquireTokenSilent(context.TODO(), scopes) if err != nil { // cache miss, authenticate with another AcquireToken... method result, err = confidentialClient.AcquireTokenByCredential(context.TODO(), scopes) if err != nil { // TODO: handle error log.Err(err).Msgf("graphapi.InitGraphClient: failed to acquire access token: %s", err) } } return &GraphApi{ TenantName: creds.TenantName, SessionToken: result.AccessToken, ApiURL: "https://graph.microsoft.com/v1.0", Logger: logger.Logger, Session: &http.Client{}, } } func (api *GraphApi) NewRequest(method string, uri string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, uri, body) if err != nil { api.Logger.Err(err).Msgf("graphapi.NewRequest: Failed to build %s \"%s\" request. %s", method, uri, err) } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", api.SessionToken)) req.Header.Add("Content-Type", "application/json") req.Header.Add("ConsistencyLevel", "eventual") return req, nil } func (api *GraphApi) QueryUser(upn string) (UserAccount, error) { if upn == "" { api.Logger.Warn().Str("tip", "Check the UPN you are querying is not nil.").Str("hint", "If you want all users, use GraphApi.GetUsers() method.").Msg("Cannot search for nil UPN.") return UserAccount{}, fmt.Errorf("Cannot search for nil UPN.") } uri := fmt.Sprintf("%s/users/%s", api.ApiURL, upn) req, err := api.NewRequest("GET", uri, nil) if err != nil { api.Logger.Err(err).Msg("graphapi.QueryUser: failed to build request") } res, err := api.Session.Do(req) if err != nil { api.Logger.Err(err).Msg("graphapi.QueryUser: failed to get a response from API") } defer res.Body.Close() if res.StatusCode == http.StatusNotFound { return UserAccount{}, fmt.Errorf("User not found with UPN: %s", upn) } body, err := io.ReadAll(res.Body) if err != nil { api.Logger.Err(err).Msg("graphapi.QueryUser: failed to read response body") } var userAccount UserAccount err = json.Unmarshal(body, &userAccount) if err != nil { api.Logger.Err(err).Msg("could not unmarshal userAccount") } return userAccount, nil } func (api *GraphApi) CreateUser(userInfo UserAccount) (UserAccount, error) { marshaledData, err := json.Marshal(userInfo) if err != nil { api.Logger.Err(err).Msg("could not marshal the provided parameter") } uri := fmt.Sprintf("%s/users", api.ApiURL) // api.Logger.Debug().Msg(fmt.Sprintf("Marshaled User: %v", string(marshaledData))) req, err := api.NewRequest("POST", uri, bytes.NewBuffer(marshaledData)) if err != nil { api.Logger.Err(err).Msg("Failed to construct NewRequest") } res, err := api.Session.Do(req) if err != nil { api.Logger.Err(err).Msg("Failed to get response from user create request.") } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { api.Logger.Err(err).Msg("graphapi.QueryUser: failed to read response body") } // Handle response statuscode cases switch res.StatusCode { case 400: var graphError GraphAPIErrorResponse err = json.Unmarshal(body, &graphError) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the ERROR response.") } if graphError.Error.Message == "Another object with the same value for property userPrincipalName already exists." { api.Logger.Warn().Str("tip", "User already exists").Msgf("You tried to create %s but it already exists!", userInfo.UserPrincipalName) return api.QueryUser(userInfo.UserPrincipalName) } // api.Logger.Debug().Int("statusCode", res.StatusCode).Str("response", string(body)).Str("marshaledData", fmt.Sprintf("%v", graphError)).Send() return UserAccount{}, fmt.Errorf("%v: %s", graphError.Error.Code, graphError.Error.Message) case 201: var createdAccount UserAccount err = json.Unmarshal(body, &createdAccount) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the response.") } return createdAccount, nil default: return UserAccount{}, fmt.Errorf("Received an unexpected error code %v", res.StatusCode) } } func (api *GraphApi) UpdateUser(userInfo UserAccount) (UserAccount, error) { marshaledData, err := json.Marshal(userInfo) if err != nil { api.Logger.Err(err).Msg("could not marshal the provided parameter") } api.Logger.Debug().Msgf("%+v", string(marshaledData)) uri := fmt.Sprintf("%s/users/%s", api.ApiURL, userInfo.UserPrincipalName) // api.Logger.Debug().Msg(fmt.Sprintf("Marshaled User: %v", string(marshaledData))) req, err := api.NewRequest("PATCH", uri, bytes.NewBuffer(marshaledData)) if err != nil { api.Logger.Err(err).Msg("Failed to construct NewRequest") } res, err := api.Session.Do(req) if err != nil { api.Logger.Err(err).Msg("Failed to get response from user create request.") } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { api.Logger.Err(err).Msg("graphapi.QueryUser: failed to read response body") } // Handle response statuscode cases switch res.StatusCode { case http.StatusBadRequest: var graphError GraphAPIErrorResponse err = json.Unmarshal(body, &graphError) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the ERROR response.") } api.Logger.Debug().Int("statusCode", res.StatusCode).Str("response", string(body)).Str("marshaledData", fmt.Sprintf("%v", graphError)).Send() return UserAccount{}, fmt.Errorf("%v: %s", graphError.Error.Code, graphError.Error.Message) case http.StatusNotFound: var graphError GraphAPIErrorResponse err = json.Unmarshal(body, &graphError) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the ERROR response.") } if graphError.Error.Code == "Request_ResourceNotFound" { api.Logger.Error().Str("tip", "User does not exist").Msgf("You tried to modify %s but it does not exist!", userInfo.UserPrincipalName) return UserAccount{}, fmt.Errorf("You tried to modify %s but it does not exist!", userInfo.UserPrincipalName) } api.Logger.Debug().Int("statusCode", res.StatusCode).Str("response", string(body)).Str("marshaledData", fmt.Sprintf("%v", graphError)).Send() return UserAccount{}, fmt.Errorf("%v: %s", graphError.Error.Code, graphError.Error.Message) case http.StatusNoContent: api.Logger.Info().Str("tip", "You may want to sleep for a few seconds after updating a user's properties.").Msgf("Successfully updated %s", userInfo.UserPrincipalName) return api.QueryUser(userInfo.UserPrincipalName) default: var graphError GraphAPIErrorResponse err = json.Unmarshal(body, &graphError) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the ERROR response.") } if graphError.Error.Message == "Insufficient privileges to complete the operation." { api.Logger.Warn().Str("tip", "You may be trying to set a property that isn't allowed.").Msgf("You tried to modify %s but you were unauthorized!", userInfo.UserPrincipalName) return UserAccount{}, fmt.Errorf("Unable to update account %v %+v", res.StatusCode, string(body)) } return UserAccount{}, fmt.Errorf("Received an unexpected error code %v %+v", res.StatusCode, string(body)) } } func (api *GraphApi) GetUsersGroups(userId string) ([]Group, error) { if userId == "" { api.Logger.Warn().Str("tip", "Check the userId you are querying is not nil.").Str("hint", "If you want all users, use GraphApi.GetUsers() method.").Msg("Cannot search for nil UPN.") return []Group{}, fmt.Errorf("Cannot search for nil userId.") } uri := fmt.Sprintf("%s/users/%s/memberOf", api.ApiURL, userId) req, err := api.NewRequest("GET", uri, nil) if err != nil { api.Logger.Err(err).Msg("Failed to build request") } res, err := api.Session.Do(req) if err != nil { api.Logger.Err(err).Msg("Failed to get a response from API") } defer res.Body.Close() switch res.StatusCode { case http.StatusNotFound: return []Group{}, fmt.Errorf("User not found with userId: %s", userId) case http.StatusOK: body, err := io.ReadAll(res.Body) if err != nil { api.Logger.Err(err).Msg("Failed to read response body") } var memberships GroupMemberships err = json.Unmarshal(body, &memberships) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the response") } return memberships.Value, nil default: return []Group{}, fmt.Errorf("Received unexpected status code %v", res.StatusCode) } } func (api *GraphApi) AssignUserToGroups(userId string, groupIds []string) ([]string, []GraphAPIError, error) { if userId == "" { return []string{}, []GraphAPIError{}, fmt.Errorf("Invalid userId parameter. Reading: '%v'", userId) } var wg sync.WaitGroup var mutex sync.Mutex var addedGroups []string var failedGroups []GraphAPIError for _, groupId := range groupIds { wg.Add(1) go func(groupId string) { defer wg.Done() uri := fmt.Sprintf("%s/groups/%s/members/$ref", api.ApiURL, groupId) body := GroupAppend{ ODataID: fmt.Sprintf("%s/users/%s", api.ApiURL, userId), } api.Logger.Debug().Msgf("Constructed URI: %s", uri) api.Logger.Debug().Msgf("Constructed request body: %+v", body) marshaledRequestBody, err := json.Marshal(body) if err != nil { api.Logger.Err(err).Str("assignmentFailed", groupId).Msg("Failed to marshal the request body") } api.Logger.Debug().Msgf("Marshaled request body: %v", marshaledRequestBody) req, err := api.NewRequest(http.MethodPost, uri, bytes.NewBuffer(marshaledRequestBody)) if err != nil { api.Logger.Err(err).Msg("Failed to construct the requst") } res, err := api.Session.Do(req) if err != nil { api.Logger.Err(err).Msg("Failed to get response.") } defer res.Body.Close() switch res.StatusCode { case http.StatusNoContent: mutex.Lock() addedGroups = append(addedGroups, groupId) mutex.Unlock() api.Logger.Info().Msgf("%s was added to %s", userId, groupId) case http.StatusBadRequest: body, err := io.ReadAll(res.Body) // extract the error message so we can view it later if err != nil { api.Logger.Err(err).Msg("Failed to read response body") } var graphError GraphAPIErrorResponse err = json.Unmarshal(body, &graphError) if err != nil { api.Logger.Err(err).Msg("Failed to unmarshal the ERROR response.") } mutex.Lock() failedGroups = append(failedGroups, graphError.Error) mutex.Unlock() default: api.Logger.Error().Msgf("Received unexpected status code \"%v\" while adding to group: %s", res.StatusCode, groupId) } }(groupId) } wg.Wait() return addedGroups, failedGroups, nil }