package connector import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/missionworkforce/missionmatch/internal/platform/sourcing" ) // ICIMS is the iCIMS ATS Connector. It authenticates with OAuth 2.0 client credentials (the client id and // secret load from the server side env only), caches the bearer token until just before it expires, and pages // through candidates updated since the incremental watermark. It maps each iCIMS candidate to the shared // provider independent SourcedPerson shape; the sync orchestration ingests them through the proven sourcing // path. It carries NO clearance (iCIMS clearance, if present, is self reported and stays unknown in scoring). // // The endpoint paths and the candidate field mapping follow the iCIMS Customer API and are the ONE thin layer // to confirm against a pilot tenant's live credentials; the OAuth flow, the token cache, the incremental // window, the pagination, the throttle handling, and the mapping mechanics are complete and unit tested against // a fake server. iCIMS is a FedRAMP relevant ATS, so it is admitted on the CUI path; a non authorized cloud // aggregator is not. type ICIMS struct { customerID string clientID string clientSecret string base string http *http.Client token string tokenExp time.Time } // ICIMSBase is the default iCIMS API host. NewICIMS accepts an override so a test points at a fake server. const ICIMSBase = "https://api.icims.com" // NewICIMS builds the iCIMS connector. An empty base uses the public host. func NewICIMS(customerID, clientID, clientSecret, base string, httpClient *http.Client) *ICIMS { if strings.TrimSpace(base) == "" { base = ICIMSBase } if httpClient == nil { httpClient = &http.Client{Timeout: 45 * time.Second} } return &ICIMS{customerID: customerID, clientID: clientID, clientSecret: [credential removed], base: strings.TrimRight(base, "/"), http: httpClient} } func (c *ICIMS) Name() string { return "icims" } // bearer returns a valid access token, fetching a new one via the OAuth client credentials grant when the cache // is empty or within a minute of expiry. The token request carries the client id and secret; the response // carries the token and its lifetime. func (c *ICIMS) bearer(ctx context.Context) (string, error) { if c.token != "" && time.Now().Before(c.tokenExp.Add(-time.Minute)) { return c.token, nil } form := url.Values{ "grant_type": {"client_credentials"}, "client_id": {c.clientID}, "client_secret": {c.clientSecret}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/oauth/token", strings.NewReader(form.Encode())) if err != nil { return "", fmt.Errorf("connector: build token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.http.Do(req) if err != nil { return "", fmt.Errorf("connector: token request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return "", fmt.Errorf("connector: token status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) } var tok struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { return "", fmt.Errorf("connector: decode token: %w", err) } if tok.AccessToken == "" { return "", fmt.Errorf("connector: empty access token") } c.token = tok.AccessToken life := tok.ExpiresIn if life <= 0 { life = 3600 } c.tokenExp = time.Now().Add(time.Duration(life) * time.Second) return c.token, nil } // icimsCandidate is one candidate in a search page. The field paths follow the iCIMS Customer API and are the // layer to confirm with live credentials; the shape here is provider independent enough that a small mapping // change does not touch the sync mechanics. type icimsCandidate struct { ID string `json:"id"` Name string `json:"name"` Title string `json:"title"` Location string `json:"location"` Skills []string `json:"skills"` Years int `json:"years"` Email string `json:"email"` Updated string `json:"updated"` // RFC3339 last updated timestamp } type icimsPage struct { People []icimsCandidate `json:"people"` NextPage string `json:"nextPage"` } // FetchPage pulls one page of candidates updated at or after since, maps them to SourcedPerson, and reports the // next page cursor and the max updated timestamp on the page. A 429 maps to sourcing.ErrThrottled so the sync // stops gracefully within the iCIMS daily cap. func (c *ICIMS) FetchPage(ctx context.Context, since time.Time, page string) ([]sourcing.SourcedPerson, string, time.Time, error) { token, err := c.bearer(ctx) if err != nil { return nil, "", time.Time{}, err } q := url.Values{"updatedSince": {since.UTC().Format(time.RFC3339)}} if page != "" { q.Set("page", page) } endpoint := fmt.Sprintf("%s/customers/%s/search/people?%s", c.base, url.PathEscape(c.customerID), q.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, "", time.Time{}, fmt.Errorf("connector: build search request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, "", time.Time{}, fmt.Errorf("connector: search request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusTooManyRequests { return nil, "", time.Time{}, sourcing.ErrThrottled } if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return nil, "", time.Time{}, fmt.Errorf("connector: search status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) } var pageResp icimsPage if err := json.NewDecoder(resp.Body).Decode(&pageResp); err != nil { return nil, "", time.Time{}, fmt.Errorf("connector: decode search: %w", err) } people := make([]sourcing.SourcedPerson, 0, len(pageResp.People)) var maxUpdated time.Time for _, cand := range pageResp.People { sp, updated, ok := mapICIMS(cand) if !ok { continue } people = append(people, sp) if updated.After(maxUpdated) { maxUpdated = updated } } return people, pageResp.NextPage, maxUpdated, nil } // mapICIMS maps an iCIMS candidate to a SourcedPerson (clearance intentionally absent, treated as unknown) and // parses its updated timestamp. A record with no id or name is skipped, never a partial candidate. func mapICIMS(cand icimsCandidate) (sourcing.SourcedPerson, time.Time, bool) { id := strings.TrimSpace(cand.ID) name := strings.TrimSpace(cand.Name) if id == "" || name == "" { return sourcing.SourcedPerson{}, time.Time{}, false } raw, _ := json.Marshal(cand) sp := sourcing.SourcedPerson{ ExternalID: id, FullName: name, JobTitle: strings.TrimSpace(cand.Title), Location: strings.TrimSpace(cand.Location), Skills: cand.Skills, Years: cand.Years, Raw: raw, } if e := strings.TrimSpace(cand.Email); e != "" { sp.Emails = []string{e} } updated, _ := time.Parse(time.RFC3339, cand.Updated) return sp, updated, true }