Files
Sacha Al Himdani 4c57d201a4 Make license declarations consistently MIT
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:

- Convert every source-file header to the MIT text across all comment
  styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
  SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
  "MIT License" title line
- Switch the package.json license fields, Docker image label, and
  cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
  (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
  hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
  the comma-separated years to a hyphenated range

Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-13 16:21:14 +02:00

246 lines
5.4 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Package googleworkspace provides a Google Workspace identity provider
// for SCIM synchronization using OAuth2.
package googleworkspace
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
scimclient "go.probo.inc/probo/pkg/iam/scim/bridge/client"
"go.probo.inc/probo/pkg/iam/scim/bridge/provider"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
)
var _ provider.Provider = (*Provider)(nil)
type Provider struct {
httpClient *http.Client
excludedUserNames []string
}
func New(httpClient *http.Client, excludedUserNames []string) *Provider {
return &Provider{
httpClient: httpClient,
excludedUserNames: excludedUserNames,
}
}
func (p *Provider) Name() string {
return "google-workspace"
}
func (p *Provider) isExcluded(email string) bool {
emailLower := strings.ToLower(email)
for _, excluded := range p.excludedUserNames {
if strings.ToLower(excluded) == emailLower {
return true
}
}
return false
}
func (p *Provider) ListUsers(ctx context.Context) (scimclient.Users, error) {
adminService, err := admin.NewService(ctx, option.WithHTTPClient(p.httpClient))
if err != nil {
return nil, fmt.Errorf("cannot create admin service: %w", err)
}
var allUsers scimclient.Users
pageToken := ""
for {
call := adminService.Users.List().
Customer("my_customer").
MaxResults(500).
Projection("full").
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("cannot list users: %w", err)
}
for _, u := range resp.Users {
if p.isExcluded(u.PrimaryEmail) {
continue
}
user := scimclient.User{
UserName: u.PrimaryEmail,
DisplayName: u.Name.FullName,
GivenName: u.Name.GivenName,
FamilyName: u.Name.FamilyName,
Active: !u.Suspended && !u.Archived,
ExternalID: u.Id,
}
p.extractOrganizationFields(u.Organizations, &user)
p.extractEmployeeNumber(u.ExternalIds, &user)
p.extractRelations(u.Relations, &user)
p.extractPreferredLanguage(u.Languages, &user)
allUsers = append(allUsers, user)
}
pageToken = resp.NextPageToken
if pageToken == "" {
break
}
}
return allUsers, nil
}
func (p *Provider) extractOrganizationFields(raw any, user *scimclient.User) {
if raw == nil {
return
}
data, err := json.Marshal(raw)
if err != nil {
return
}
var orgs []admin.UserOrganization
if err := json.Unmarshal(data, &orgs); err != nil {
return
}
var (
primary *admin.UserOrganization
first *admin.UserOrganization
)
for i := range orgs {
if first == nil {
first = &orgs[i]
}
if orgs[i].Primary {
primary = &orgs[i]
break
}
}
org := primary
if org == nil {
org = first
}
if org == nil {
return
}
user.Title = org.Title
user.Department = org.Department
user.CostCenter = org.CostCenter
user.EnterpriseOrganization = org.Name
user.UserType = org.Description
}
func (p *Provider) extractEmployeeNumber(raw any, user *scimclient.User) {
if raw == nil {
return
}
data, err := json.Marshal(raw)
if err != nil {
return
}
var ids []admin.UserExternalId
if err := json.Unmarshal(data, &ids); err != nil {
return
}
for _, id := range ids {
if id.Type == "organization" && id.Value != "" {
user.EmployeeNumber = id.Value
return
}
}
if len(ids) > 0 && ids[0].Value != "" {
user.EmployeeNumber = ids[0].Value
}
}
func (p *Provider) extractRelations(raw any, user *scimclient.User) {
if raw == nil {
return
}
data, err := json.Marshal(raw)
if err != nil {
return
}
var relations []admin.UserRelation
if err := json.Unmarshal(data, &relations); err != nil {
return
}
for _, rel := range relations {
if rel.Type == "manager" && rel.Value != "" {
user.ManagerValue = rel.Value
return
}
}
}
func (p *Provider) extractPreferredLanguage(raw any, user *scimclient.User) {
if raw == nil {
return
}
data, err := json.Marshal(raw)
if err != nil {
return
}
var languages []admin.UserLanguage
if err := json.Unmarshal(data, &languages); err != nil {
return
}
for _, lang := range languages {
if lang.Preference == "preferred" && lang.LanguageCode != "" {
user.PreferredLanguage = lang.LanguageCode
return
}
}
if len(languages) > 0 && languages[0].LanguageCode != "" {
user.PreferredLanguage = languages[0].LanguageCode
}
}