Skip to content

Commit

Permalink
import: create or merge user (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
wilsonehusin authored May 9, 2024
1 parent 7828f92 commit b33eb82
Show file tree
Hide file tree
Showing 12 changed files with 429 additions and 19 deletions.
92 changes: 74 additions & 18 deletions cmd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,36 +267,92 @@ func importUsers(ctx context.Context, provider pager.Pager, fh *firehydrant.Clie
return fmt.Errorf("unable to match users to FireHydrant: %w", err)
}

// Manually link users which do not have matching email addresses
// Find out which users should be pre-created in FireHydrant via SCIM / matched to existing user.
unmatched, err := store.UseQueries(ctx).ListUnmatchedExtUsers(ctx)
if err != nil {
return fmt.Errorf("unable to list unmatched users: %w", err)
}
if len(unmatched) > 0 {
console.Warnf("Please match the following users to their FireHydrant account.\n")
fhUsers, err := fh.ListUsers(ctx)
if err != nil {
return fmt.Errorf("unable to list FireHydrant users: %w", err)
if len(unmatched) == 0 {
console.Successf("All users are already matched to FireHydrant.\n")
return nil
}

// Get padding number to pretty print the information in table-like view.
idPad := console.PadStrings(unmatched, func(u store.ExtUser) int { return len(u.ID) })
emailPad := console.PadStrings(unmatched, func(u store.ExtUser) int { return len(u.Email) })

importOpts := []store.ExtUser{{ID: "[+] IMPORT ALL"}, {ID: "[<] SKIP ALL"}}
importOpts = append(importOpts, unmatched...)
selected, toImport, err := console.MultiSelectf(importOpts, func(u store.ExtUser) string {
return fmt.Sprintf("%*s %-*s %s", idPad, u.ID, emailPad, u.Email, u.Name)
}, "These users do not have a FireHydrant account. Which users should be created / merged in FireHydrant?")
if err != nil {
return fmt.Errorf("selecting users to import: %w", err)
}
switch selected[0] {
case 0:
console.Successf("[+] All users will be created in FireHydrant.\n")
case 1:
console.Warnf("[<] No users will be created in FireHydrant.\n")
if err := store.UseQueries(ctx).DeleteUnmatchedExtUsers(ctx); err != nil {
return fmt.Errorf("unable to delete unmatched users: %w", err)
}
options := []store.FhUser{{Name: "[<] SKIP"}}
options = append(options, fhUsers...)
return nil
default:
console.Warnf("Selected %d users to be imported to FireHydrant.\n", len(toImport))
}

for _, u := range unmatched {
selected, fhUser, err := console.Selectf(options, func(u store.FhUser) string {
return fmt.Sprintf("%s %s", u.Name, u.Email)
}, fmt.Sprintf("Which FireHydrant user should '%s' be imported to?", u.Name))
if err != nil {
return fmt.Errorf("selecting FireHydrant user for '%s': %w", u.Name, err)
// We now ask if all of them are to be created as new users, or if they should be matched to existing FireHydrant users.
console.Warnf("Please match the following users to a FireHydrant account.\n")
fhUsers, err := fh.ListUsers(ctx)
if err != nil {
return fmt.Errorf("unable to list FireHydrant users: %w", err)
}

namePad := console.PadStrings(fhUsers, func(u store.FhUser) int { return len(u.Name) })

matchOpts := []store.FhUser{{Name: "[+] CREATE THE REST OF USERS AS NEW"}, {Name: "[+] CREATE USER AS NEW"}}
matchOpts = append(matchOpts, fhUsers...)
for i, u := range toImport {
selected, fhUser, err := console.Selectf(matchOpts, func(u store.FhUser) string {
return fmt.Sprintf("%*s %s", namePad, u.Name, u.Email)
}, fmt.Sprintf("[%03d/%03d] Which FireHydrant user should '%s' be imported to?", i+1, len(toImport), u.Name))
if err != nil {
return fmt.Errorf("selecting FireHydrant user for '%s': %w", u.Name, err)
}
switch selected {
case 0:
console.Infof("[+] All users will be created in FireHydrant.\n")
for _, u := range unmatched[i:] {
fhUser, err := fh.CreateUser(ctx, &u)
if err != nil {
console.Warnf("unable to create user '%s': %s\n", u.Email, err.Error())
continue
}
if err := store.UseQueries(ctx).LinkExtUser(ctx, store.LinkExtUserParams{
ID: u.ID,
FhUserID: sql.NullString{String: fhUser.ID, Valid: true},
}); err != nil {
console.Warnf("unable to link user '%s': %s\n", u.Email, err.Error())
continue
}
}
if selected == 0 {
console.Infof("[<] User '%s' will not be imported to FireHydrant.\n", u.Name)
continue
return nil
case 1:
console.Infof("[+] User '%s (%s)' will be created in FireHydrant.\n", u.Name, u.Email)
scimUser, err := fh.CreateUser(ctx, &u)
if err != nil {
return fmt.Errorf("creating user '%s': %w", u.Name, err)
}
fhUser = *scimUser
fallthrough
default:
if err := store.UseQueries(ctx).LinkExtUser(ctx, store.LinkExtUserParams{
ID: u.ID,
FhUserID: sql.NullString{String: fhUser.ID, Valid: true},
}); err != nil {
return fmt.Errorf("linking user '%s': %w", u.Name, err)
console.Warnf("unable to link user '%s': %s\n", u.Email, err.Error())
continue
}
console.Successf("[=] User '%s' linked to FireHydrant user '%s'.\n", u.Email, fhUser.Email)
}
Expand Down
10 changes: 10 additions & 0 deletions console/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ var (
Errorf = color.New(color.FgHiRed).Add(color.Underline).PrintfFunc()
Warnf = color.New(color.FgHiYellow).Add(color.Bold).PrintfFunc()
)

func PadStrings[T any](elems []T, intFn func(T) int) int {
max := 0
for _, elem := range elems {
if l := intFn(elem); l > max {
max = l
}
}
return max
}
97 changes: 96 additions & 1 deletion internal/firehydrant/firehydrant.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package firehydrant

import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"

"github.com/firehydrant/signals-migrator/console"
"github.com/firehydrant/signals-migrator/store"
"github.com/firehydrant/terraform-provider-firehydrant/firehydrant"
)
Expand All @@ -13,6 +17,9 @@ import (
// satisfy the Pager interface, since that's not what we're using it for.
type Client struct {
client firehydrant.Client

apiKey string
apiURL string
}

func NewClient(apiKey string, apiURL string) (*Client, error) {
Expand All @@ -24,7 +31,11 @@ func NewClient(apiKey string, apiURL string) (*Client, error) {
if err != nil {
return nil, fmt.Errorf("initializing FireHydrant client: %w", err)
}
return &Client{client: client}, nil
return &Client{
client: client,
apiKey: apiKey,
apiURL: apiURL,
}, nil
}

func (c *Client) ListTeams(ctx context.Context) ([]store.FhTeam, error) {
Expand Down Expand Up @@ -99,6 +110,30 @@ func (c *Client) ListUsers(ctx context.Context) ([]store.FhUser, error) {
return users, nil
}

func (c *Client) fetchUser(ctx context.Context, email string) (*store.FhUser, error) {
opts := firehydrant.GetUserParams{Query: email}
resp, err := c.client.GetUsers(ctx, opts)
if err != nil {
return nil, fmt.Errorf("fetching users from FireHydrant: %w", err)
}
if len(resp.Users) == 0 {
return nil, fmt.Errorf("fetching users from FireHydrant: no users found")
}
if len(resp.Users) > 1 {
console.Warnf("multiple users found for email %s, selecting first one with ID: '%s'", email, resp.Users[0].ID)
}
user := store.FhUser{
ID: resp.Users[0].ID,
Name: resp.Users[0].Name,
Email: resp.Users[0].Email,
}
if err := store.UseQueries(ctx).InsertFhUser(ctx, store.InsertFhUserParams(user)); err != nil {
return nil, fmt.Errorf("storing user '%s' to database: %w", user.Email, err)
}

return &user, nil
}

// MatchUsers attempts to pair users in the parameter with its FireHydrant User counterpart.
// Returns: a list of users which were not successfully matched.
func (c *Client) MatchUsers(ctx context.Context) error {
Expand Down Expand Up @@ -132,3 +167,63 @@ func (c *Client) PairUsers(ctx context.Context, fhUserID string, extUserID strin
ID: extUserID,
})
}

type SCIMUser interface {
Username() string
FamilyName() string
GivenName() string
PrimaryEmail() string
}

var (
_ SCIMUser = &store.ExtUser{}
)

// CreateUser provisions user via SCIM. Terraform client does not support this, therefore
// we are making the barebones request directly.
func (c *Client) CreateUser(ctx context.Context, u SCIMUser) (*store.FhUser, error) {
payload := map[string]any{
"userName": u.Username(),
"name": map[string]any{
"familyName": u.FamilyName(),
"givenName": u.GivenName(),
},
"emails": []map[string]any{
{
"value": u.PrimaryEmail(),
"primary": true,
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("converting user payload to JSON: %w", err)
}
buf := bytes.NewBuffer(body)
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/scim/v2/Users", c.apiURL), buf)
if err != nil {
return nil, fmt.Errorf("composing request to create user: %w", err)
}
req.Header.Set("Authorization", c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("creating user: unexpected status code %d", resp.StatusCode)
}
return c.fetchUser(ctx, u.PrimaryEmail())
}

func (c *Client) CreateUsers(ctx context.Context, users []SCIMUser) ([]store.FhUser, error) {
created := []store.FhUser{}
for _, u := range users {
user, err := c.CreateUser(ctx, u)
if err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
created = append(created, *user)
}
return created, nil
}
27 changes: 27 additions & 0 deletions internal/firehydrant/firehydrant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package firehydrant_test

import (
"context"
"testing"

"github.com/firehydrant/signals-migrator/internal/firehydrant"
"github.com/firehydrant/signals-migrator/internal/testkit"
)

func TestFireHydrantClient(t *testing.T) {
ctx := testkit.NewStore(t, context.Background())
ts := testkit.NewHTTPServer(t)

client, err := firehydrant.NewClient("testing-only", ts.URL)
if err != nil {
t.Fatalf("error creating FireHydrant client: %s", err)
}

t.Run("ListTeams", func(t *testing.T) {
teams, err := client.ListTeams(ctx)
if err != nil {
t.Fatalf("error listing teams: %s", err)
}
testkit.GoldenJSON(t, teams)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"id": "47016143-6547-483a-b68a-5220b21681fd",
"name": "AAAA IPv6 migration strategy",
"slug": "aaaa-ipv6-migration-strategy"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"data": [
{
"created_at": "2024-04-03T23:55:34.887Z",
"created_by": {
"email": "[email protected]",
"id": "a993700a-1cb1-40b8-a2f0-82834dc67017",
"name": "John Doe",
"source": "firehydrant_user"
},
"description": "",
"functionalities": [],
"id": "47016143-6547-483a-b68a-5220b21681fd",
"memberships": [
{
"default_incident_role": null,
"schedule": null,
"user": {
"created_at": "2024-04-03T23:37:49.043Z",
"email": "[email protected]",
"id": "a993700a-1cb1-40b8-a2f0-82834dc67017",
"name": "Wilson Husin",
"signals_enabled_notification_types": ["slack", "sms"],
"slack_linked?": true,
"slack_user_id": "U06JS9XJCKB",
"updated_at": "2024-04-04T02:38:05.809Z"
}
}
],
"name": "AAAA IPv6 migration strategy",
"owned_checklist_templates": [],
"owned_functionalities": [],
"owned_runbooks": [],
"owned_services": [
{
"alert_on_add": true,
"auto_add_responding_team": false,
"created_at": "2024-04-12T19:55:18.604Z",
"description": "",
"id": "3fccf801-10b3-4ecc-b2ce-1a2f3d514343",
"labels": {},
"name": "AAAA IPv6",
"service_tier": 5,
"slug": "aaaa-ipv6",
"updated_at": "2024-04-12T19:55:18.604Z"
}
],
"responding_services": [],
"services": [
{
"alert_on_add": true,
"auto_add_responding_team": false,
"created_at": "2024-04-12T19:55:18.604Z",
"description": "",
"id": "3fccf801-10b3-4ecc-b2ce-1a2f3d514343",
"labels": {},
"name": "AAAA IPv6",
"service_tier": 5,
"slug": "aaaa-ipv6",
"updated_at": "2024-04-12T19:55:18.604Z"
}
],
"signals_ical_url": "https://app.firehydrant.io/signals/65f77d1c-21b2-4a2d-8ac6-d4446d6472c1/569b69da-30ee-4d7e-afc4-ffdf6885ae54/team/47016143-6547-483a-b68a-5220b21681fd.ics",
"slug": "aaaa-ipv6-migration-strategy",
"updated_at": "2024-04-10T22:36:24.185Z"
}
],
"pagination": {
"count": 11,
"items": 11,
"last": 1,
"next": null,
"page": 1,
"pages": 1,
"prev": null
}
}
Loading

0 comments on commit b33eb82

Please sign in to comment.