lib/scim/scim_user.go (626 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package scim
import (
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"google.golang.org/grpc/codes" /* copybara-comment */
"google.golang.org/grpc/status" /* copybara-comment */
"github.com/golang/protobuf/jsonpb" /* copybara-comment */
"github.com/golang/protobuf/proto" /* copybara-comment */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/auth" /* copybara-comment: auth */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/errutil" /* copybara-comment: errutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/handlerfactory" /* copybara-comment: handlerfactory */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/strutil" /* copybara-comment: strutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/timeutil" /* copybara-comment: timeutil */
cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */
spb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/scim/v2" /* copybara-comment: go_proto */
)
var (
// As used by storage.BuildFilters(), this maps the SCIM data model
// filter path names to a slice path of where the field exists in
// the storage data model. SCIM names are expected to be the lowercase
// version of the names from the SCIM spec.
scimUserFilterMap = map[string]func(p proto.Message) string{
"active": func(p proto.Message) string {
if acctProto(p).State == storage.StateActive {
return "true"
}
return "false"
},
"displayname": func(p proto.Message) string {
return acctProto(p).GetProfile().Name
},
"emails": func(p proto.Message) string {
list := []string{}
for _, link := range acctProto(p).ConnectedAccounts {
list = append(list, link.GetProperties().Email)
}
return strutil.JoinNonEmpty(list, " ")
},
"externalid": func(p proto.Message) string {
return acctProto(p).GetProperties().Subject
},
"id": func(p proto.Message) string {
return acctProto(p).GetProperties().Subject
},
"locale": func(p proto.Message) string {
profile := acctProto(p).GetProfile()
if len(profile.Locale) > 0 {
return profile.Locale
}
// Returning the language match, if set, is not perfect by semantic meaning but better than nothing.
return profile.Language
},
"preferredlanguage": func(p proto.Message) string {
profile := acctProto(p).GetProfile()
if len(profile.Language) > 0 {
return profile.Language
}
return profile.Locale
},
"name.formatted": func(p proto.Message) string {
return formattedName(acctProto(p))
},
"name.givenname": func(p proto.Message) string {
return acctProto(p).GetProfile().GivenName
},
"name.familyname": func(p proto.Message) string {
return acctProto(p).GetProfile().FamilyName
},
"name.middlename": func(p proto.Message) string {
return acctProto(p).GetProfile().MiddleName
},
"timezone": func(p proto.Message) string {
return acctProto(p).GetProfile().ZoneInfo
},
"username": func(p proto.Message) string {
return acctProto(p).GetProperties().Subject
},
}
scimEmailFilterMap = map[string]func(p proto.Message) string{
"$ref": func(p proto.Message) string {
return emailRef(linkProto(p))
},
"value": func(p proto.Message) string {
return linkProto(p).GetProperties().Email
},
"primary": func(p proto.Message) string {
if linkProto(p).Primary {
return "true"
}
return "false"
},
}
emailPathRE = regexp.MustCompile(`^emails\[(.*)\](\.primary)?$`)
photoPathRE = regexp.MustCompile(`^photos.*\.value$`)
)
//////////////////////////////////////////////////////////////////
func acctProto(p proto.Message) *cpb.Account {
acct, ok := p.(*cpb.Account)
if !ok {
return &cpb.Account{}
}
return acct
}
func linkProto(p proto.Message) *cpb.ConnectedAccount {
link, ok := p.(*cpb.ConnectedAccount)
if !ok {
return &cpb.ConnectedAccount{}
}
return link
}
// MeFactory creates SCIM /Me request handlers.
func MeFactory(store storage.Store, domainURL, path string) *handlerfactory.Options {
return &handlerfactory.Options{
TypeName: "user",
PathPrefix: path,
HasNamedIdentifiers: false,
Service: func() handlerfactory.Service {
return &scimMe{
s: New(store),
store: store,
domainURL: domainURL,
userPath: userPath(path),
}
},
}
}
type scimMe struct {
s *Scim
store storage.Store
domainURL string
userPath string
user *scimUser
}
// Setup initializes the handler
func (h *scimMe) Setup(r *http.Request, tx storage.Tx) (int, error) {
r.ParseForm()
h.user = &scimUser{
s: h.s,
store: h.store,
domainURL: h.domainURL,
userPath: userPath(h.userPath),
input: &spb.Patch{},
}
return h.user.Setup(r, tx)
}
// LookupItem returns true if the named object is found
func (h *scimMe) LookupItem(r *http.Request, name string, vars map[string]string) bool {
return h.user.LookupItem(r, h.user.auth.ID.Subject, vars)
}
// NormalizeInput transforms a request's object to standard form, as needed
func (h *scimMe) NormalizeInput(r *http.Request, name string, vars map[string]string) error {
return h.user.NormalizeInput(r, name, vars)
}
// Get sends a GET method response
func (h *scimMe) Get(r *http.Request, name string) (proto.Message, error) {
return h.user.Get(r, name)
}
// Post receives a POST method request
func (h *scimMe) Post(r *http.Request, name string) (proto.Message, error) {
return h.user.Post(r, name)
}
// Put receives a PUT method request
func (h *scimMe) Put(r *http.Request, name string) (proto.Message, error) {
return h.user.Put(r, name)
}
// Patch receives a PATCH method request
func (h *scimMe) Patch(r *http.Request, name string) (proto.Message, error) {
return h.user.Patch(r, name)
}
// Remove receives a DELETE method request
func (h *scimMe) Remove(r *http.Request, name string) (proto.Message, error) {
return h.user.Remove(r, name)
}
// CheckIntegrity provides an opportunity to check the result of any changes
func (h *scimMe) CheckIntegrity(r *http.Request) *status.Status {
return h.user.CheckIntegrity(r)
}
// Save can save any valid changes that occured during the request
func (h *scimMe) Save(r *http.Request, tx storage.Tx, name string, vars map[string]string, desc, typeName string) error {
return h.user.Save(r, tx, name, vars, desc, typeName)
}
//////////////////////////////////////////////////////////////////
// UserFactory creates SCIM /Users/<id> request handlers
func UserFactory(store storage.Store, domainURL, path string) *handlerfactory.Options {
return &handlerfactory.Options{
TypeName: "user",
PathPrefix: path,
HasNamedIdentifiers: true,
Service: func() handlerfactory.Service {
return &scimUser{
s: New(store),
store: store,
domainURL: domainURL,
userPath: userPath(path),
input: &spb.Patch{},
}
},
}
}
type scimUser struct {
s *Scim
store storage.Store
domainURL string
userPath string
item *cpb.Account
input *spb.Patch
save *cpb.Account
auth *auth.Context
tx storage.Tx
}
// Setup initializes the handler
func (h *scimUser) Setup(r *http.Request, tx storage.Tx) (int, error) {
auth, err := auth.FromContext(r.Context())
if err != nil {
return http.StatusUnauthorized, err
}
if r.Method == http.MethodPatch {
if err := jsonpb.Unmarshal(r.Body, h.input); err != nil && err != io.EOF {
return http.StatusBadRequest, err
}
}
h.auth = auth
h.tx = tx
return http.StatusOK, nil
}
// LookupItem returns true if the named object is found
func (h *scimUser) LookupItem(r *http.Request, name string, vars map[string]string) bool {
realm := getRealm(r)
acct := &cpb.Account{}
acct, _, err := h.s.LoadAccount(name, realm, h.auth.IsAdmin, h.tx)
if err != nil {
return false
}
h.item = acct
return true
}
// NormalizeInput transforms a request's object to standard form, as needed
func (h *scimUser) NormalizeInput(r *http.Request, name string, vars map[string]string) error {
if r.Method != http.MethodPatch {
return nil
}
if len(h.input.Schemas) != 1 || h.input.Schemas[0] != scimPatchSchema {
return fmt.Errorf("PATCH requires schemas set to only be %q", scimPatchSchema)
}
return nil
}
// Get sends a GET method response
func (h *scimUser) Get(r *http.Request, name string) (proto.Message, error) {
user := newScimUser(h.item, getRealm(r), h.domainURL, h.userPath)
if err := h.s.LoadGroupMembershipForUser(user, getRealm(r), true /*resolveDisplayName*/, h.tx); err != nil {
return nil, err
}
return user, nil
}
// Post receives a POST method request
func (h *scimUser) Post(r *http.Request, name string) (proto.Message, error) {
return nil, fmt.Errorf("POST not allowed")
}
// Put receives a PUT method request
func (h *scimUser) Put(r *http.Request, name string) (proto.Message, error) {
return nil, fmt.Errorf("PUT not allowed")
}
// Patch receives a PATCH method request
func (h *scimUser) Patch(r *http.Request, name string) (proto.Message, error) {
h.save = &cpb.Account{}
proto.Merge(h.save, h.item)
for i, patch := range h.input.Operations {
src := patchSource(patch.Value)
var dst *string
path := patch.Path
// When updating a photo from the list, always update the photo in the primary profile.
if photoPathRE.MatchString(path) {
path = "photo"
} else if emailPathRE.MatchString(path) {
path = "emails"
}
switch path {
case "active":
// TODO: support for boolean input for "active" field instead of strings
switch {
case (patch.Op == "remove" && len(src) == 0) || (patch.Op == "replace" && src == "false"):
h.save.State = storage.StateDisabled
case src == "true" && (patch.Op == "add" || patch.Op == "replace"):
h.save.State = storage.StateActive
default:
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("invalid active operation %q or value %q", patch.Op, patch.Value))
}
case "name.formatted":
dst = &h.save.Profile.FormattedName
if patch.Op == "remove" || len(src) == 0 {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("cannot set %q to an empty value", path))
}
case "name.familyName":
dst = &h.save.Profile.FamilyName
case "name.givenName":
dst = &h.save.Profile.GivenName
case "name.middleName":
dst = &h.save.Profile.MiddleName
case "displayName":
dst = &h.save.Profile.Name
if patch.Op == "remove" || len(src) == 0 {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("cannot set %q to an empty value", path))
}
case "preferredLanguage":
dst = &h.save.Profile.Language
if len(src) > 0 && !timeutil.IsLocale(src) {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("%q is not a recognized locale", path))
}
case "profileUrl":
dst = &h.save.Profile.Profile
case "locale":
dst = &h.save.Profile.Locale
if len(src) > 0 && !timeutil.IsLocale(src) {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("%q is not a recognized locale", path))
}
case "timezone":
dst = &h.save.Profile.ZoneInfo
if len(src) > 0 && !timeutil.IsTimeZone(src) {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("%q is not a recognized time zone", src))
}
case "emails":
if patch.Op == "add" {
// SCIM extension for linking accounts.
if src != auth.LinkAuthorizationHeader {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("%q must be set to %q", src, auth.LinkAuthorizationHeader))
}
if err := h.linkEmail(r); err != nil {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, err.Error())
}
break
}
// Standard SCIM email functionality.
link, match, err := selectLink(patch.Path, emailPathRE, scimEmailFilterMap, h.save)
if err != nil {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, err.Error())
}
dst = nil // operation can be skipped by logic after this switch block (i.e. no destination to write)
if link == nil {
break
}
if len(match[2]) == 0 {
// When match[2] is empty, the operation applies to the entire email object.
if patch.Op != "remove" {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("path %q only supported for remove", path))
}
if len(h.save.ConnectedAccounts) < 2 {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("cannot unlink the only email address for a given account"))
}
// Unlink account
for idx, connect := range h.save.ConnectedAccounts {
if connect.Properties.Subject == link.Properties.Subject {
h.save.ConnectedAccounts = append(h.save.ConnectedAccounts[:idx], h.save.ConnectedAccounts[idx+1:]...)
if err := h.s.RemoveAccountLookup(link.LinkRevision, getRealm(r), link.Properties.Subject, r, h.auth.ID, h.tx); err != nil {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("service dependencies not available; try again later"))
}
break
}
}
} else {
// This logic is valid for all patch.Op operations.
primary := strings.ToLower(src) == "true" && patch.Op != "remove"
if primary {
// Make all entries not primary, then set the primary below
for _, entry := range h.save.ConnectedAccounts {
entry.Primary = false
}
}
link.Primary = primary
}
case "photo":
dst = &h.save.Profile.Picture
if !strutil.IsImageURL(src) {
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("invalid photo URL %q", src))
}
default:
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("invalid path %q", path))
}
if patch.Op != "remove" && len(src) == 0 {
return nil, fmt.Errorf("operation %d: cannot set an empty value", i)
}
if dst == nil {
continue
}
switch patch.Op {
case "add":
fallthrough
case "replace":
*dst = src
case "remove":
*dst = ""
default:
return nil, errutil.NewIndexError(codes.InvalidArgument, errutil.ErrorPath("scim", "user", "profile", path), i, fmt.Sprintf("invalid op %q", patch.Op))
}
}
return newScimUser(h.save, getRealm(r), h.domainURL, h.userPath), nil
}
// Remove receives a DELETE method request
func (h *scimUser) Remove(r *http.Request, name string) (proto.Message, error) {
h.save = &cpb.Account{}
proto.Merge(h.save, h.item)
for _, link := range h.save.ConnectedAccounts {
if link.Properties == nil || len(link.Properties.Subject) == 0 {
continue
}
if err := h.s.RemoveAccountLookup(link.LinkRevision, getRealm(r), link.Properties.Subject, r, h.auth.ID, h.tx); err != nil {
return nil, fmt.Errorf("service dependencies not available; try again later")
}
}
h.save.ConnectedAccounts = []*cpb.ConnectedAccount{}
h.save.State = "DELETED"
return nil, nil
}
// CheckIntegrity provides an opportunity to check the result of any changes
func (h *scimUser) CheckIntegrity(*http.Request) *status.Status {
return nil
}
// Save can save any valid changes that occured during the request
func (h *scimUser) Save(r *http.Request, tx storage.Tx, name string, vars map[string]string, desc, typeName string) error {
if h.save == nil {
return nil
}
return h.s.SaveAccount(h.item, h.save, desc, h.auth.ID.Subject, getRealm(r), r, h.tx)
}
func (h *scimUser) linkEmail(r *http.Request) error {
// This scope check is done here because it has only been determined now
// that the standard user token needs it for the specific patch requested.
if !strutil.ContainsWord(h.auth.ID.Scope, "link") {
return fmt.Errorf("bearer token unauthorized for scope %q", "link")
}
// Linked token has been validated and scope "link" check has also been done.
if h.auth.LinkedID == nil {
return status.Errorf(codes.FailedPrecondition, "linked account bearer token header %q not provided", auth.LinkAuthorizationHeader)
}
linkSub := h.auth.LinkedID.Subject
idSub := h.save.Properties.Subject
if linkSub == idSub {
return fmt.Errorf("the accounts provided are already linked together")
}
linkAcct, _, err := h.s.LoadAccount(linkSub, getRealm(r), false, h.tx)
if err != nil {
return err
}
if linkAcct.State != storage.StateActive {
return fmt.Errorf("the link account is not found or no longer available")
}
for _, acct := range linkAcct.ConnectedAccounts {
if acct.Properties == nil || len(acct.Properties.Subject) == 0 {
continue
}
lookup := &cpb.AccountLookup{
Subject: h.save.Properties.Subject,
Revision: acct.LinkRevision,
State: storage.StateActive,
}
if err := h.s.SaveAccountLookup(lookup, getRealm(r), acct.Properties.Subject, r, h.auth.ID, h.tx); err != nil {
return fmt.Errorf("service dependencies not available; try again later")
}
acct.LinkRevision++
h.save.ConnectedAccounts = append(h.save.ConnectedAccounts, acct)
}
linkAcct.ConnectedAccounts = make([]*cpb.ConnectedAccount, 0)
linkAcct.State = "LINKED"
linkAcct.Owner = h.save.Properties.Subject
if err = h.s.SaveAccount(nil, linkAcct, "LINK account", h.auth.ID.Subject, getRealm(r), r, h.tx); err != nil {
return err
}
return nil
}
//////////////////////////////////////////////////////////////////
// UsersFactory creates SCIM Users request handlers.
func UsersFactory(store storage.Store, domainURL, path string) *handlerfactory.Options {
return &handlerfactory.Options{
TypeName: "users",
PathPrefix: path,
HasNamedIdentifiers: true,
Service: func() handlerfactory.Service {
return &scimUsers{
s: New(store),
store: store,
domainURL: domainURL,
userPath: userPath(path),
}
},
}
}
type scimUsers struct {
s *Scim
store storage.Store
domainURL string
userPath string
id *ga4gh.Identity
tx storage.Tx
}
// Setup initializes the handler
func (h *scimUsers) Setup(r *http.Request, tx storage.Tx) (int, error) {
a, err := auth.FromContext(r.Context())
if err != nil {
return http.StatusUnauthorized, err
}
h.id = a.ID
h.tx = tx
return http.StatusOK, nil
}
// LookupItem returns true if the named object is found
func (h *scimUsers) LookupItem(r *http.Request, name string, vars map[string]string) bool {
return true
}
// NormalizeInput transforms a request's object to standard form, as needed
func (h *scimUsers) NormalizeInput(r *http.Request, name string, vars map[string]string) error {
return nil
}
// Get sends a GET method response
func (h *scimUsers) Get(r *http.Request, name string) (proto.Message, error) {
filters, err := storage.BuildFilters(httputils.QueryParam(r, "filter"), scimUserFilterMap)
if err != nil {
return nil, err
}
// "startIndex" is a 1-based starting location, to be converted to an offset for the query.
start := httputils.QueryParamInt(r, "startIndex")
if start == 0 {
start = 1
}
offset := start - 1
// "count" is the number of results desired on this request's page.
max := httputils.QueryParamInt(r, "count")
if len(httputils.QueryParam(r, "count")) == 0 {
max = storage.DefaultPageSize
}
results, err := h.store.MultiReadTx(storage.AccountDatatype, getRealm(r), storage.MatchAllUsers, storage.MatchAllIDs, filters, offset, max, &cpb.Account{}, h.tx)
if err != nil {
return nil, err
}
accts := make(map[string]*cpb.Account)
subjects := []string{}
for _, entry := range results.Entries {
if acct, ok := entry.Item.(*cpb.Account); ok {
accts[acct.Properties.Subject] = acct
subjects = append(subjects, acct.Properties.Subject)
}
}
sort.Strings(subjects)
realm := getRealm(r)
var list []*spb.User
for _, sub := range subjects {
list = append(list, newScimUser(accts[sub], realm, h.domainURL, h.userPath))
}
resp := &spb.ListUsersResponse{
Schemas: []string{scimListSchema},
TotalResults: uint32(offset + results.MatchCount),
ItemsPerPage: uint32(len(list)),
StartIndex: uint32(start),
Resources: list,
}
return resp, nil
}
// Post receives a POST method request
func (h *scimUsers) Post(r *http.Request, name string) (proto.Message, error) {
return nil, fmt.Errorf("POST not allowed")
}
// Put receives a PUT method request
func (h *scimUsers) Put(r *http.Request, name string) (proto.Message, error) {
return nil, fmt.Errorf("PUT not allowed")
}
// Patch receives a PATCH method request
func (h *scimUsers) Patch(r *http.Request, name string) (proto.Message, error) {
return nil, fmt.Errorf("PATCH not allowed")
}
// Remove receives a DELETE method request
func (h *scimUsers) Remove(r *http.Request, name string) (proto.Message, error) {
return nil, fmt.Errorf("DELETE not allowed")
}
// CheckIntegrity provides an opportunity to check the result of any changes
func (h *scimUsers) CheckIntegrity(*http.Request) *status.Status {
return nil
}
// Save can save any valid changes that occured during the request
func (h *scimUsers) Save(r *http.Request, tx storage.Tx, name string, vars map[string]string, desc, typeName string) error {
return nil
}
////////////////////////////////////////////////////////////
func newScimUser(acct *cpb.Account, realm, domainURL, abstractPath string) *spb.User {
var emails []*spb.Attribute
var photos []*spb.Attribute
primaryPic := acct.GetProfile().GetPicture()
if len(primaryPic) > 0 {
photos = append(photos, &spb.Attribute{Value: strutil.ToURL(primaryPic, domainURL), Primary: true})
}
for _, ca := range acct.ConnectedAccounts {
if len(ca.Properties.Email) > 0 {
emails = append(emails, &spb.Attribute{
Value: ca.Properties.Email,
ExtensionVerified: ca.Properties.EmailVerified,
Primary: ca.Primary,
Ref: emailRef(ca),
})
}
if ca.Profile == nil {
continue
}
if pic := ca.GetProfile().GetPicture(); len(pic) > 0 && pic != primaryPic {
photos = append(photos, &spb.Attribute{Value: strutil.ToURL(pic, domainURL)})
}
}
path := strings.ReplaceAll(abstractPath, "{realm}", realm)
path = strings.ReplaceAll(path, "{name}", acct.Properties.Subject)
return &spb.User{
Schemas: []string{scimUserSchema},
Id: acct.Properties.Subject,
ExternalId: acct.Properties.Subject,
Meta: &spb.ResourceMetadata{
ResourceType: "User",
Created: timeutil.TimestampString(int64(acct.Properties.Created)),
LastModified: timeutil.TimestampString(int64(acct.Properties.Modified)),
Location: domainURL + path,
Version: strconv.FormatInt(acct.Revision, 10),
},
Name: &spb.Name{
Formatted: formattedName(acct),
FamilyName: acct.Profile.FamilyName,
GivenName: acct.Profile.GivenName,
MiddleName: acct.Profile.MiddleName,
},
DisplayName: acct.Profile.Name,
ProfileUrl: acct.Profile.Profile,
PreferredLanguage: acct.Profile.Language,
Locale: acct.Profile.Locale,
Timezone: acct.Profile.ZoneInfo,
UserName: acct.Properties.Subject,
Emails: emails,
Photos: photos,
Active: acct.State == storage.StateActive,
}
}
func userPath(abstract string) string {
path := strings.ReplaceAll(abstract, "/Me", "/Users")
if strings.Contains(abstract, "{name}") {
return path
}
return path + "/{name}"
}
func formattedName(acct *cpb.Account) string {
profile := acct.GetProfile()
name := profile.FormattedName
if len(name) == 0 {
name = strutil.JoinNonEmpty([]string{profile.GivenName, profile.MiddleName, profile.FamilyName}, " ")
}
if len(name) == 0 {
name = profile.Name
}
return name
}
func selectLink(selector string, re *regexp.Regexp, filterMap map[string]func(p proto.Message) string, acct *cpb.Account) (*cpb.ConnectedAccount, []string, error) {
match := re.FindStringSubmatch(selector)
if match == nil {
return nil, nil, nil
}
filter, err := storage.BuildFilters(match[1], filterMap)
if err != nil {
return nil, nil, err
}
for _, link := range acct.ConnectedAccounts {
if storage.MatchProtoFilters(filter, link) {
return link, match, nil
}
}
return nil, match, nil
}
func emailRef(link *cpb.ConnectedAccount) string {
return "email/" + link.Provider + "/" + link.Properties.Subject
}
func patchSource(value string) string {
// TODO: handle more complex substructure and remove "object" field.
return value
}