lib/ic/info_release.go (547 lines of code) (raw):

// Copyright 2020 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 ic import ( "encoding/base64" "fmt" "net/http" "sort" "strings" "time" "google.golang.org/grpc/codes" /* copybara-comment */ "google.golang.org/grpc/status" /* copybara-comment */ "github.com/golang/protobuf/jsonpb" /* copybara-comment */ "bitbucket.org/creachadair/stringset" /* copybara-comment */ "github.com/pborman/uuid" /* copybara-comment */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/consentsapi" /* copybara-comment: consentsapi */ "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/httputils" /* copybara-comment: httputils */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/hydra" /* copybara-comment: hydra */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/timeutil" /* copybara-comment: timeutil */ glog "github.com/golang/glog" /* copybara-comment */ cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */ cspb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/store/consents" /* copybara-comment: go_proto */ ) const ( rememberedConsentExpires = 90 * 24 * time.Hour maxRememberedConsent = 200 ) func (s *Service) informationReleasePage(id *ga4gh.Identity, stateID, clientName, scope string) string { args := toInformationReleasePageArgs(id, stateID, clientName, scope, s.consentDashboardURL) sb := &strings.Builder{} s.infomationReleasePageTmpl.Execute(sb, args) return sb.String() } func toInformationReleasePageArgs(id *ga4gh.Identity, stateID, clientName, scope, consentDashboard string) *informationReleasePageArgs { dashboardURL := strings.ReplaceAll(consentDashboard, "${USER_ID}", id.Subject) args := &informationReleasePageArgs{ ID: id.Subject, ApplicationName: clientName, Scope: scope, AssetDir: assetPath, Information: map[string][]*informationItem{}, State: stateID, ConsentDashboardURL: dashboardURL, } for _, s := range strings.Split(scope, " ") { switch { case s == "openid": continue case s == "offline": args.Offline = true case s == "profile": if len(id.Name) != 0 { args.Information["Profile"] = append(args.Information["Profile"], &informationItem{ ID: "profile.name", Title: "Name", Value: id.Name, }) } if len(id.Email) != 0 { args.Information["Profile"] = append(args.Information["Profile"], &informationItem{ ID: "profile.email", Title: "Email", Value: id.Email, }) } if len(id.Picture) > 0 || len(id.Locale) > 0 { args.Information["Profile"] = append(args.Information["Profile"], &informationItem{ ID: "profile.others", Title: "Others", Value: "Picture,Locale", }) } case s == passportScope || s == ga4ghScope: for _, v := range id.VisaJWTs { info, err := visaToInformationItem(v) if err != nil { glog.Errorf("convert visa to info failed: %v", err) continue } args.Information["Visas"] = append(args.Information["Visas"], info) } case s == "account_admin": args.Information["Permission"] = append(args.Information["Permission"], &informationItem{ ID: "account_admin", Title: "account_admin", Value: "manage (modify) this account", }) case s == "link": args.Information["Permission"] = append(args.Information["Permission"], &informationItem{ ID: "link", Title: "link", Value: "link this account to other accounts", }) case s == "identities": if len(id.Identities) == 0 { continue } var ids []string for k := range id.Identities { ids = append(ids, k) } args.Information["Profile"] = append(args.Information["Profile"], &informationItem{ ID: "identities", Title: "Identities", Value: strings.Join(ids, ","), }) default: // Should not reach here, scope has been validated on Hydra. glog.Errorf("Unknown scope: %s", s) } } return args } func visaToInformationItem(s string) (*informationItem, error) { v, err := ga4gh.NewVisaFromJWT(ga4gh.VisaJWT(s)) if err != nil { return nil, err } marshaler := jsonpb.Marshaler{} ss, err := marshaler.MarshalToString(visaToConsentVisa(v)) if err != nil { return nil, err } id := base64.StdEncoding.EncodeToString([]byte(ss)) return &informationItem{ ID: id, Title: string(v.Data().Assertion.Type) + "@" + string(v.Data().Assertion.Source), Value: string(v.Data().Assertion.Value), }, nil } func visaToConsentVisa(v *ga4gh.Visa) *cspb.RememberedConsentPreference_Visa { return &cspb.RememberedConsentPreference_Visa{ Type: string(v.Data().Assertion.Type), Source: string(v.Data().Assertion.Source), By: string(v.Data().Assertion.By), Iss: v.Data().Issuer, } } type informationItem struct { Title string Value string ID string } type informationReleasePageArgs struct { ApplicationName string Scope string AssetDir string ID string Offline bool Information map[string][]*informationItem State string ConsentDashboardURL string } // normalizeRememberedConsentPreference change ANYTHING_NEEDED to release item. func normalizeRememberedConsentPreference(rcp *cspb.RememberedConsentPreference) { if rcp.ReleaseType != cspb.RememberedConsentPreference_ANYTHING_NEEDED { return } rcp.ReleaseProfileName = true rcp.ReleaseProfileEmail = true rcp.ReleaseProfileOther = true rcp.ReleaseAccountAdmin = true rcp.ReleaseLink = true rcp.ReleaseIdentities = true } func scopedIdentity(identity *ga4gh.Identity, rcp *cspb.RememberedConsentPreference, scope, iss, subject string, iat, nbf, exp int64) (*ga4gh.Identity, error) { normalizeRememberedConsentPreference(rcp) var scopes []string for _, s := range strings.Split(scope, " ") { switch s { case "link": if !rcp.ReleaseLink { continue } case "account_admin": if !rcp.ReleaseAccountAdmin { continue } } scopes = append(scopes, s) } claims := &ga4gh.Identity{ Issuer: iss, Subject: subject, IssuedAt: iat, NotBefore: nbf, ID: uuid.New(), Expiry: exp, Scope: strings.Join(scopes, " "), IdentityProvider: identity.IdentityProvider, } // TODO: remove this extra "ga4gh" check once DDAP is compatible. if hasScopes("identities", scope, matchFullScope) || hasScopes(passportScope, scope, matchFullScope) || hasScopes(ga4ghScope, scope, matchFullScope) { if rcp.ReleaseIdentities { claims.Identities = identity.Identities } } if hasScopes("profile", scope, matchFullScope) { if rcp.ReleaseProfileName { claims.Name = identity.Name claims.FamilyName = identity.FamilyName claims.GivenName = identity.GivenName claims.Username = identity.Username } if rcp.ReleaseProfileEmail { claims.Email = identity.Email } if rcp.ReleaseProfileOther { claims.Picture = identity.Picture claims.Locale = identity.Locale } } if hasScopes("ga4gh_passport_v1", scope, matchFullScope) { if rcp.ReleaseType == cspb.RememberedConsentPreference_ANYTHING_NEEDED { claims.VisaJWTs = identity.VisaJWTs } else { visas, err := releasedVisas(identity.VisaJWTs, rcp.SelectedVisas) if err != nil { return nil, err } claims.VisaJWTs = visas } } return claims, nil } // releasedVisas finds all released visa. func releasedVisas(visas []string, rVisas []*cspb.RememberedConsentPreference_Visa) ([]string, error) { var res []string for _, visa := range visas { match, err := matchVisa(visa, rVisas) if err != nil { return nil, err } if match { res = append(res, visa) } } return res, nil } // matchVisa checks if the given visa is in the released list. func matchVisa(visaStr string, rVisas []*cspb.RememberedConsentPreference_Visa) (bool, error) { v, err := ga4gh.NewVisaFromJWT(ga4gh.VisaJWT(visaStr)) if err != nil { return false, err } visa := visaToConsentVisa(v) for _, rv := range rVisas { if visa.Type != rv.Type { continue } if visa.Source != rv.Source { continue } if visa.By != rv.By { continue } if visa.Iss != rv.Iss { continue } return true, nil } return false, nil } // AcceptInformationRelease is the HTTP handler for ".../inforelease/accept" endpoint. func (s *Service) AcceptInformationRelease(w http.ResponseWriter, r *http.Request) { r.ParseForm() challenge, redirect, err := s.acceptInformationRelease(r) if err == nil { httputils.WriteRedirect(w, r, redirect) return } if s.useHydra && len(challenge) > 0 { hydra.SendConsentReject(w, r, s.httpClient, s.hydraAdminURL, challenge, err) } else { httputils.WriteError(w, err) } } // checkboxIDToConsentVisa convert ConsentVisa from base64 json string. func checkboxIDToConsentVisa(s string) (*cspb.RememberedConsentPreference_Visa, error) { b, err := base64.StdEncoding.DecodeString(s) if err != nil { return nil, fmt.Errorf("base64 decoding remembered consent preference failed: %v", err) } visa := &cspb.RememberedConsentPreference_Visa{} if err := jsonpb.UnmarshalString(string(b), visa); err != nil { return nil, fmt.Errorf("json decoding remembered consent preference failed: %v", err) } return visa, nil } // toRememberedConsentPreference reads RememberedConsentPreference from request. func toRememberedConsentPreference(r *http.Request) (*cspb.RememberedConsentPreference, error) { now := time.Now() rcp := &cspb.RememberedConsentPreference{ RequestMatchType: cspb.RememberedConsentPreference_NONE, ReleaseType: cspb.RememberedConsentPreference_SELECTED, CreateTime: timeutil.TimestampProto(now), ExpireTime: timeutil.TimestampProto(now.Add(rememberedConsentExpires)), } for k, v := range r.PostForm { switch k { case "state": continue case "profile.name": rcp.ReleaseProfileName = true case "profile.email": rcp.ReleaseProfileEmail = true case "profile.others": rcp.ReleaseProfileOther = true case "account_admin": rcp.ReleaseAccountAdmin = true case "link": rcp.ReleaseLink = true case "identities": rcp.ReleaseIdentities = true case "select-anything": rcp.ReleaseType = cspb.RememberedConsentPreference_ANYTHING_NEEDED case "remember": if len(v) == 0 { return nil, fmt.Errorf("remember format invalid") } switch v[0] { case "remember-samesubset": rcp.RequestMatchType = cspb.RememberedConsentPreference_SUBSET case "remember-any": rcp.RequestMatchType = cspb.RememberedConsentPreference_ANYTHING case "remember-none": rcp.RequestMatchType = cspb.RememberedConsentPreference_NONE default: return nil, fmt.Errorf("remember value invalid: %v", v[0]) } default: visa, err := checkboxIDToConsentVisa(k) if err != nil { return nil, err } rcp.SelectedVisas = append(rcp.SelectedVisas, visa) } } return rcp, nil } // acceptInformationRelease returns challenge, redirect and status error func (s *Service) acceptInformationRelease(r *http.Request) (_, _ string, ferr error) { stateID := httputils.QueryParam(r, "state") if len(stateID) == 0 { return "", "", status.Errorf(codes.InvalidArgument, "missing %q parameter", "state") } rcp, err := toRememberedConsentPreference(r) if err != nil { return "", "", status.Errorf(codes.InvalidArgument, "accept info release read consent failed: %v", err) } tx, err := s.store.Tx(true) if err != nil { return "", "", status.Errorf(codes.Unavailable, "accept info release transaction creation failed: %v", err) } defer func() { err := tx.Finish() if ferr == nil && err != nil { ferr = status.Errorf(codes.Internal, "accept info release transaction finish failed: %v", err) } }() state := &cpb.LoginState{} err = s.store.ReadTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, state, tx) if err != nil { return "", "", status.Errorf(codes.Internal, "accept info release datastore read failed: %v", err) } // The temporary state for information releasing process can be only used once. err = s.store.DeleteTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, tx) if err != nil { return "", "", status.Errorf(codes.Internal, "accept info release datastore delete failed: %v", err) } challenge := state.ConsentChallenge rcp.ClientName = state.ClientName // Save RememberedConsent if user select remember it. if rcp.RequestMatchType != cspb.RememberedConsentPreference_NONE { if err := s.cleanupRememberedConsent(state.Subject, state.Realm, tx); err != nil { return challenge, "", err } rID := uuid.New() rcp.RequestedScopes = strings.Split(state.Scope, " ") err = s.store.WriteTx(storage.RememberedConsentDatatype, state.Realm, state.Subject, rID, storage.LatestRev, rcp, nil, tx) if err != nil { return challenge, "", status.Errorf(codes.Internal, "accept info release datastore write remember consent failed: %v", err) } } cfg, err := s.loadConfig(tx, state.Realm) if err != nil { return challenge, "", status.Errorf(codes.Internal, "accept info release loadConfig() failed: %v", err) } secrets, err := s.loadSecrets(tx) if err != nil { return challenge, "", status.Errorf(codes.Internal, "accept info release loadSecrets() failed: %v", err) } acct, st, err := s.scim.LoadAccount(state.Subject, state.Realm, false, tx) if err != nil { return challenge, "", status.Errorf(httputils.RPCCode(st), "accept info release LoadAccount() failed: %v", err) } id, err := s.accountToIdentity(r.Context(), acct, cfg, secrets) if err != nil { return challenge, "", status.Errorf(codes.Internal, "accept info release accountToIdentity() failed: %v", err) } now := time.Now().Unix() scoped, err := scopedIdentity(id, rcp, state.Scope, s.getIssuerString(), state.Subject, now, id.NotBefore, id.Expiry) if err != nil { return challenge, "", status.Errorf(codes.Internal, "accept info release scopedIdentity() failed: %v", err) } if s.useHydra { addr, err := s.hydraAcceptConsent(scoped, state) if err != nil { return challenge, "", status.Errorf(codes.Internal, "accept info release hydraAcceptConsent() failed: %v", err) } return challenge, addr, nil } return challenge, "", status.Errorf(codes.Unimplemented, "oidc service not supported") } // cleanupRememberedConsent delete expired RememberedConsent or oldest RememberedConsent if count of RememberedConsent over maxRememberedConsent func (s *Service) cleanupRememberedConsent(user, realm string, tx storage.Tx) error { rcs, err := findRememberedConsentsByUser(s.store, user, realm, "", 0, maxRememberedConsent+10, tx) if err != nil { return status.Errorf(codes.Unavailable, "cleanupRememberedConsent %v", err) } var list []*rememberedConsentPreferenceWithID for k, v := range rcs { list = append(list, &rememberedConsentPreferenceWithID{id: k, rcp: v}) } // order by expire time. sort.Slice(list, func(i int, j int) bool { return list[i].rcp.ExpireTime.Seconds < list[j].rcp.ExpireTime.Seconds }) i := 0 // delete item over limit not matter if it still valid. for ; len(list)-i >= maxRememberedConsent; i++ { if err := s.store.DeleteTx(storage.RememberedConsentDatatype, realm, user, list[i].id, storage.LatestRev, tx); err != nil { return status.Errorf(codes.Unavailable, "cleanupRememberedConsent delete item over limit failed: %v", err) } } now := time.Now().Unix() // delete expired item. for ; i < len(list); i++ { if list[i].rcp.ExpireTime.Seconds > now { break } if err := s.store.DeleteTx(storage.RememberedConsentDatatype, realm, user, list[i].id, storage.LatestRev, tx); err != nil { return status.Errorf(codes.Unavailable, "cleanupRememberedConsent delete expired item failed: %v", err) } } return nil } type rememberedConsentPreferenceWithID struct { rcp *cspb.RememberedConsentPreference id string } // RejectInformationRelease is the HTTP handler for ".../inforelease/reject" endpoint. func (s *Service) RejectInformationRelease(w http.ResponseWriter, r *http.Request) { r.ParseForm() challenge, err := s.rejectInformationRelease(r) if err == nil { glog.Errorln("rejectInformationRelease() should return err") err = status.Errorf(codes.Internal, "unknown err from rejectInformationRelease()") } if s.useHydra && len(challenge) > 0 { hydra.SendConsentReject(w, r, s.httpClient, s.hydraAdminURL, challenge, err) } else { httputils.WriteError(w, err) } } // rejectInformationRelease returns challenge and status error func (s *Service) rejectInformationRelease(r *http.Request) (_ string, ferr error) { stateID := httputils.QueryParam(r, "state") if len(stateID) == 0 { return "", status.Errorf(codes.InvalidArgument, "missing %q parameter", "state") } tx, err := s.store.Tx(true) if err != nil { return "", status.Errorf(codes.Unavailable, "reject info release transaction creation failed: %v", err) } defer func() { err := tx.Finish() if ferr == nil && err != nil { ferr = status.Errorf(codes.Internal, "reject info release transaction finish failed: %v", err) } }() state := &cpb.LoginState{} err = s.store.ReadTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, state, tx) if err != nil { return "", status.Errorf(codes.Internal, "reject info release datastore read failed: %v", err) } // The temporary state for information releasing process can be only used once. err = s.store.DeleteTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, tx) if err != nil { return "", status.Errorf(codes.Internal, "reject info release datastore delete failed: %v", err) } challenge := state.ConsentChallenge return challenge, errutil.WithErrorReason("user_denied", status.Errorf(codes.Unauthenticated, "User denied releasing consent")) } // findRememberedConsent for user and consent request. // will match the remembered consent and incoming request in order: // 1. match type = anything remembered consent // 2. remembered consent has exact the same scope with request // 3. request scope is subset of remembered consent scope func findRememberedConsent(store storage.Store, requestedScope []string, subject, realm, clientName string, tx storage.Tx) (*cspb.RememberedConsentPreference, error) { rcps, err := findRememberedConsentsByUser(store, subject, realm, clientName, 0, maxRememberedConsent, tx) if err != nil { return nil, err } var matchSame *cspb.RememberedConsentPreference var matchSubset *cspb.RememberedConsentPreference reqScope := scopesToStringSet(requestedScope) for _, rcp := range rcps { if rcp.RequestMatchType == cspb.RememberedConsentPreference_ANYTHING { return rcp, nil } sco := scopesToStringSet(rcp.RequestedScopes) // do not early return here to keep stable order: ANYTHING, SAME, SUBSET if sco.Equals(reqScope) { matchSame = rcp } if sco.IsSubset(reqScope) { matchSubset = rcp } } if matchSame != nil { return matchSame, nil } return matchSubset, nil } func scopesToStringSet(scopes []string) stringset.Set { set := stringset.Set{} for _, s := range scopes { set.Add(s) } return set } // findRememberedConsentsByUser returns all RememberedConsents of user of client. func findRememberedConsentsByUser(store storage.Store, subject, realm, clientName string, offset, pageSize int, tx storage.Tx) (map[string]*cspb.RememberedConsentPreference, error) { results, err := store.MultiReadTx(storage.RememberedConsentDatatype, realm, subject, storage.MatchAllIDs, nil, offset, pageSize, &cspb.RememberedConsentPreference{}, tx) if err != nil { return nil, status.Errorf(codes.Unavailable, "findRememberedConsentsByUser MultiReadTx() failed: %v", err) } res := map[string]*cspb.RememberedConsentPreference{} if len(results.Entries) == 0 { return res, nil } now := time.Now().Unix() for _, entry := range results.Entries { rcp, ok := entry.Item.(*cspb.RememberedConsentPreference) if !ok { return nil, status.Errorf(codes.Internal, "findRememberedConsentsByUser obj type incorrect: user=%s, id=%s", subject, entry.ItemID) } // remove expired items if rcp.ExpireTime.Seconds < now { continue } // filter for clientName if len(clientName) > 0 && rcp.ClientName != clientName { continue } res[entry.ItemID] = rcp } return res, nil } // clients fetchs oauth clients func (s *Service) clients(tx storage.Tx) (map[string]*cpb.Client, error) { cfg, err := s.loadConfig(tx, storage.DefaultRealm) if err != nil { return nil, status.Errorf(codes.Unavailable, "load clients failed: %v", err) } return cfg.Clients, nil } func (s *Service) consentService() *consentsapi.Service { return &consentsapi.Service{ Store: s.store, Clients: s.clients, } }