lib/scim/scim.go (168 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 implements a SCIM-like interface for group and user management. package scim import ( "fmt" "net/http" "sort" "time" "github.com/gorilla/mux" /* copybara-comment */ "github.com/golang/protobuf/proto" /* copybara-comment */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */ 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 */ ) const ( scimGroupSchema = "urn:ietf:params:scim:schemas:core:2.0:Group" scimListSchema = "urn:ietf:params:scim:api:messages:2.0:ListResponse" scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp" scimUserSchema = "urn:ietf:params:scim:schemas:core:2.0:User" ) // Scim is a System for Cross-domain Identity Management. // It bridges the internal account representation with an externally // facing API based on the SCIM v2 standard. type Scim struct { store storage.Store } // New creates a new SCIM. func New(store storage.Store) *Scim { return &Scim{ store: store, } } // LoadAccount loads one internal account from storage. It will filter disabled or deleted accounts unless // `anyState` is set to true. func (s *Scim) LoadAccount(name, realm string, anyState bool, tx storage.Tx) (*cpb.Account, int, error) { acct := &cpb.Account{} status, err := s.readTx(storage.AccountDatatype, realm, storage.DefaultUser, name, storage.LatestRev, acct, tx) if err != nil { return nil, status, err } // TODO: move state checks to storage package. if acct.State != storage.StateActive && !anyState { return nil, http.StatusNotFound, fmt.Errorf("not found") } return acct, http.StatusOK, nil } // LookupAccount loads one internal account based on supplying a federated account identitifer such as an email address. // It will filter disabled or deleted accounts unless `anyState` is set to true. func (s *Scim) LookupAccount(fedAcct, realm string, anyState bool, tx storage.Tx) (*cpb.Account, int, error) { lookup, err := s.LoadAccountLookup(realm, fedAcct, tx) if err != nil { return nil, http.StatusServiceUnavailable, err } if lookup == nil { return nil, http.StatusNotFound, fmt.Errorf("subject not found") } return s.LoadAccount(lookup.Subject, realm, anyState, tx) } // LoadAccountLookup loads an account reference structure (AccountLookup) that points an federated account identifier // such as an email address with where the account is stored internally. Note that multiple external identifiers // or emails can map to one internal account (i.e. account linking). func (s *Scim) LoadAccountLookup(realm, acct string, tx storage.Tx) (*cpb.AccountLookup, error) { lookup := &cpb.AccountLookup{} status, err := s.readTx(storage.AccountLookupDatatype, realm, storage.DefaultUser, acct, storage.LatestRev, lookup, tx) if err != nil && status == http.StatusNotFound { return nil, nil } return lookup, err } // SaveAccountLookup puts an account lookup reference structure in storage. func (s *Scim) SaveAccountLookup(lookup *cpb.AccountLookup, realm, fedAcct string, r *http.Request, id *ga4gh.Identity, tx storage.Tx) error { lookup.Revision++ lookup.CommitTime = float64(time.Now().UnixNano()) / 1e9 if err := s.store.WriteTx(storage.AccountLookupDatatype, realm, storage.DefaultUser, fedAcct, lookup.Revision, lookup, storage.MakeConfigHistory("link account", storage.AccountLookupDatatype, lookup.Revision, lookup.CommitTime, r, id.Subject, nil, lookup), tx); err != nil { return fmt.Errorf("service storage unavailable: %v, retry later", err) } return nil } // RemoveAccountLookup removes an account lookup reference structure from storage by marking it as DELETED. // Providence is maintained by not fully deleting the data. func (s *Scim) RemoveAccountLookup(rev int64, realm, fedAcct string, r *http.Request, id *ga4gh.Identity, tx storage.Tx) error { lookup := &cpb.AccountLookup{ Subject: "", Revision: rev, State: "DELETED", } if err := s.SaveAccountLookup(lookup, realm, fedAcct, r, id, tx); err != nil { return err } return nil } // SaveAccount puts an internal account structure in storage. func (s *Scim) SaveAccount(oldAcct, newAcct *cpb.Account, desc, subject, realm string, r *http.Request, tx storage.Tx) error { newAcct.Revision++ newAcct.Properties.Modified = float64(time.Now().UnixNano()) / 1e9 if newAcct.Properties.Created == 0 { if oldAcct != nil && oldAcct.Properties.Created != 0 { newAcct.Properties.Created = oldAcct.Properties.Created } else { newAcct.Properties.Created = newAcct.Properties.Modified } } if err := s.store.WriteTx(storage.AccountDatatype, realm, storage.DefaultUser, newAcct.Properties.Subject, newAcct.Revision, newAcct, storage.MakeConfigHistory(desc, storage.AccountDatatype, newAcct.Revision, newAcct.Properties.Modified, r, subject, oldAcct, newAcct), tx); err != nil { return fmt.Errorf("service storage unavailable: %v, retry later", err) } return nil } // LoadGroup loads a user group. func (s *Scim) LoadGroup(name, realm string, tx storage.Tx) (*spb.Group, error) { group := &spb.Group{} st, err := s.readTx(storage.GroupDatatype, realm, name, storage.DefaultID, storage.LatestRev, group, tx) if err != nil { if st == http.StatusNotFound { return nil, nil } return nil, fmt.Errorf("loading group %q failed: %v", name, err) } return group, nil } // LoadGroupMember loads a user membership record as part of a group. func (s *Scim) LoadGroupMember(groupName, memberName, realm string, tx storage.Tx) (*spb.Member, error) { member := &spb.Member{} st, err := s.readTx(storage.GroupMemberDatatype, realm, groupName, memberName, storage.LatestRev, member, tx) if err != nil { if st == http.StatusNotFound { return nil, nil } return nil, fmt.Errorf("loading group %q member %q failed: %v", groupName, memberName, err) } return member, nil } // LoadGroupMembershipForUser populates the Groups field with a set of group metadata to which the user belongs // based on email addresses. resolveDisplayName will fill in the group's UI label by doing extra storage lookups // when this information is for use by an end user. func (s *Scim) LoadGroupMembershipForUser(user *spb.User, realm string, resolveDisplayName bool, tx storage.Tx) error { groups := []*spb.Attribute{} for _, email := range user.GetEmails() { if len(email.Value) == 0 { continue } results, err := s.store.MultiReadTx(storage.GroupMemberDatatype, realm, storage.MatchAllGroups, email.Value, nil, 0, 500, &spb.Member{}, tx) if err != nil { return err } for _, entry := range results.Entries { if _, ok := entry.Item.(*spb.Member); ok { displayName := "" if resolveDisplayName { group, err := s.LoadGroup(entry.GroupID, realm, tx) if err != nil { return err } displayName = group.DisplayName } groups = append(groups, &spb.Attribute{ Display: displayName, Value: entry.GroupID, Ref: fmt.Sprintf("group/%s/%s", entry.GroupID, email.Value), }) } } } sort.SliceStable(groups, func(i, j int) bool { return groups[i].Ref < groups[j].Ref }) user.Groups = groups return nil } func (s *Scim) readTx(datatype, realm, user, id string, rev int64, item proto.Message, tx storage.Tx) (int, error) { err := s.store.ReadTx(datatype, realm, user, id, rev, item, tx) if err == nil { return http.StatusOK, nil } if storage.ErrNotFound(err) { if len(id) > 0 && id != storage.DefaultID { return http.StatusNotFound, fmt.Errorf("%s %q not found", datatype, id) } return http.StatusNotFound, fmt.Errorf("%s not found", datatype) } return http.StatusServiceUnavailable, fmt.Errorf("service storage unavailable: %v, retry later", err) } func getRealm(r *http.Request) string { if r == nil { return storage.DefaultRealm } if realm, ok := mux.Vars(r)["realm"]; ok && len(realm) > 0 { return realm } return storage.DefaultRealm }