Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filling some gaps in the DTS manifest #78

Merged
merged 9 commits into from
Aug 13, 2024
76 changes: 46 additions & 30 deletions auth/kbase_auth_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ type KBaseAuthServer struct {
AccessToken string
}

// a record containing information about a user logged into the KBase Auth2
// server
type kbaseUserInfo struct {
// KBase username
Username string `json:"user"`
// KBase user display name
Display string `json:"display"`
// User email address
Email string `json:"email"`
// Identities with associated providers
Idents []struct {
Provider string `json:"provider"`
UserName string `json:"provusername"`
} `json:"idents"`
}

// here's how KBase represents errors in responses to API calls
type kbaseAuthErrorResponse struct {
HttpCode int `json:"httpcode"`
Expand All @@ -55,15 +71,6 @@ type kbaseAuthErrorResponse struct {
Time time.Duration `json:"time"`
}

// here's what we use to fetch user information
type kbaseAuthUserInfo struct {
Username string `json:"user"`
Idents []struct {
Provider string `json:"provider"`
UserName string `json:"provusername"`
} `json:"idents"`
}

// here's a set of instances to the KBase auth server, mapped by OAuth2
// access token
var instances map[string]*KBaseAuthServer
Expand All @@ -87,7 +94,7 @@ func NewKBaseAuthServer(accessToken string) (*KBaseAuthServer, error) {
}

// verify that the access token works (i.e. that the user is logged in)
userInfo, err := server.getUserInfo()
userInfo, err := server.kbaseUserInfo()
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -162,8 +169,9 @@ func (server *KBaseAuthServer) get(resource string) (*http.Response, error) {
return client.Do(req)
}

func (server *KBaseAuthServer) getUserInfo() (kbaseAuthUserInfo, error) {
var userInfo kbaseAuthUserInfo
// returns information for the current KBase user accessing the auth server
func (server *KBaseAuthServer) kbaseUserInfo() (kbaseUserInfo, error) {
var userInfo kbaseUserInfo
resp, err := server.get("me")
if err != nil {
return userInfo, err
Expand All @@ -180,31 +188,39 @@ func (server *KBaseAuthServer) getUserInfo() (kbaseAuthUserInfo, error) {
return userInfo, err
}
err = json.Unmarshal(body, &userInfo)
return userInfo, err
}

// returns the username for the current KBase user accessing the
// KBase auth server
func (server *KBaseAuthServer) Username() (string, error) {
userInfo, err := server.getUserInfo()
return userInfo.Username, err
// make sure we have at least one ORCID for this user
if len(userInfo.Idents) < 1 {
return userInfo, fmt.Errorf("KBase Auth2: No providers associated with this user!")
}
foundOrcid := false
for _, pid := range userInfo.Idents {
if pid.Provider == "OrcID" {
foundOrcid = true
break
}
}
if !foundOrcid {
return userInfo, fmt.Errorf("KBase Auth2: No ORCID IDs associated with this user!")
}
Comment on lines +196 to +205
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth setting the OrcID here, so that in the UserInfo function you don't do the same iteration through the list of Idents?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it matters, unless a user has tens of thousands of ORCIDs associated(!). As it is now, kbaseUserInfo mirrors the information maintained by the KBase auth server, which doesn't prioritize the first ORCID. This mirrored structure is separate from our interpretation, which provides some clarity as to what we assume, which to me is more valuable than avoiding an extra loop.

return userInfo, err
}

// returns the current KBase user's registered ORCID identifiers (and/or an error)
// a user can have 0, 1, or many associated ORCID identifiers
func (server *KBaseAuthServer) Orcids() ([]string, error) {
userInfo, err := server.getUserInfo()
// returns a normalized user info record for the current KBase user
func (server *KBaseAuthServer) UserInfo() (UserInfo, error) {
kbUserInfo, err := server.kbaseUserInfo()
if err != nil {
return nil, err
return UserInfo{}, err
}
if len(userInfo.Idents) < 1 {
return nil, fmt.Errorf("No ORCID IDs associated with this user!")
userInfo := UserInfo{
Name: kbUserInfo.Display,
Username: kbUserInfo.Username,
Email: kbUserInfo.Email,
}
orcidIds := make([]string, 0)
for _, pid := range userInfo.Idents {
for _, pid := range kbUserInfo.Idents {
if pid.Provider == "OrcID" {
orcidIds = append(orcidIds, pid.UserName)
userInfo.Orcid = pid.UserName
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it guaranteed that there is only ever one Orcid associated with the user?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but I don't know what we would do with more than one ORCID, so I'm assuming for now that we need to select one, and that the one we select is the first one.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the user has different OrcIDs associated with their accounts on different platforms? (well, I know what would happen -- nothing. The more useful case to think about is where they have a bunch of OrcIDs assoc'd with their KBase account, one of which is associated with their account on another platform).

Not deliberately trying to be annoying here -- just thinking of the kinds of annoying situations that may pop up in the wild.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! I guess my logic is that I'd like to start simple, encounter real problems, and address accordingly. At least we have separate KBase and DTS user information so we can articulate our assumptions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been burned too many times by trying to keep it simple and assuming that KBase is doing sensible things...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it! Here's an issue: #81

}
}
return orcidIds, nil
return userInfo, nil
}
31 changes: 7 additions & 24 deletions auth/kbase_auth_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,34 +44,17 @@ func TestNewKBaseAuthServer(t *testing.T) {
assert.Nil(err, "Authentication server constructor triggered an error")
}

// tests whether the authentication server can return the username for the
// for the owner of the developer token
func TestUsername(t *testing.T) {
// tests whether the authentication server can return information for the
// user associated with the specified developer token
func TestUserInfo(t *testing.T) {
jeff-cohere marked this conversation as resolved.
Show resolved Hide resolved
assert := assert.New(t)
devToken := os.Getenv("DTS_KBASE_DEV_TOKEN")
server, _ := NewKBaseAuthServer(devToken)
assert.NotNil(server)
username, err := server.Username()
userInfo, err := server.UserInfo()
assert.Nil(err)
assert.True(len(username) > 0)
}

// tests whether the authentication server can return the proper credentials
// for the owner of the developer token
func TestOrchids(t *testing.T) {
assert := assert.New(t)
devToken := os.Getenv("DTS_KBASE_DEV_TOKEN")
orcid := os.Getenv("DTS_KBASE_TEST_ORCID")
assert.False(orcid == "")
server, _ := NewKBaseAuthServer(devToken)
orcids, err := server.Orcids()
assert.Nil(err)
var foundOrcid bool
for _, id := range orcids {
if orcid == id {
foundOrcid = true
break
}
}
assert.True(foundOrcid)
assert.True(len(userInfo.Username) > 0)
assert.True(len(userInfo.Email) > 0)
assert.Equal(os.Getenv("DTS_KBASE_TEST_ORCID"), userInfo.Orcid)
}
36 changes: 36 additions & 0 deletions auth/user_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2023 The KBase Project and its Contributors
// Copyright (c) 2023 Cohere Consulting, LLC
//
// 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 auth

// a record containing information about a DTS user
type UserInfo struct {
// user's name (human-readable and display-friendly)
Name string
// username used to access DTS
Username string
// user's email address
Email string
// ORCID identifier associated with this user
Orcid string
// organization with which this user is affiliated
Organization string
}
39 changes: 39 additions & 0 deletions frictionless/frictionless.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ type DataPackage struct {
Licenses []DataLicense `json:"licenses,omitempty"`
// a list identifying the sources for this resource (optional)
Sources []DataSource `json:"sources,omitempty"`
// a timestamp indicated when the package was created
Created string `json:"created,omitempty"`
jeff-cohere marked this conversation as resolved.
Show resolved Hide resolved
// the profile of this descriptor per the DataPackage profiles specification
// (https://specs.frictionlessdata.io/profiles/#language)
Profile string `json:"profile,omitempty"`
// a title or one sentence description for the data package
Title string `json:"title,omitempty"`
// a Markdown description of the data package
Description string `json:"description,omitempty"`
// a URL for a web address related to the data package
Homepage string `json:"homepage,omitempty"`
// a version string identifying the version of the data package, conforming to
// semantic versioning if relevant
Version string `json:"version,omitempty"`
// an array of string keywords to assist users searching for the data package
// in catalogs
Keywords []string `json:"keywords,omitempty"`
// list of contributors to the data package
Contributors []Contributor `json:"contributors,omitempty"`
// an image to use for this data package (URL or POSIX path)
Image string `json:"image,omitempty"`
// a machine-readable set of instructions for processing
Instructions json.RawMessage `json:"instructions,omitempty"`
}

// a Frictionless data resource describing a file in a search
Expand Down Expand Up @@ -107,3 +130,19 @@ type DataLicense struct {
// the descriptive title of the license (optional)
Title string `json:"title,omitempty"`
}

// information about a contributor to a DataPackage
type Contributor struct {
// name/title of the contributor (name for person, name/title of organization)
Title string `json:"title"`
// the contributor's email address
Email string `json:"email"`
// a fully qualified http URL pointing to a relevant location online for the
// contributor
Path string `json:"path"`
// the role of the contributor ("author", "publisher", "maintainer",
// "wrangler", "contributor")
Role string `json:"role"`
// a string describing the contributor's organization
Organization string `json:"organization"`
}
51 changes: 27 additions & 24 deletions services/prototype.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,34 +54,31 @@ type prototype struct {
Server *http.Server
}

// authorize clients for the DTS, returning the client's ORCID ID and an error
// describing any issue encountered
func authorize(authorizationHeader string) (string, error) {
// authorize clients for the DTS, returning information about the user
// corresponding to the token in the header (or an error describing any issue
// encountered)
func authorize(authorizationHeader string) (auth.UserInfo, error) {
if !strings.Contains(authorizationHeader, "Bearer") {
return "", fmt.Errorf("Invalid authorization header")
return auth.UserInfo{}, fmt.Errorf("Invalid authorization header")
}
b64Token := authorizationHeader[len("Bearer "):]
accessTokenBytes, err := base64.StdEncoding.DecodeString(b64Token)
if err != nil {
return "", huma.Error401Unauthorized(err.Error())
return auth.UserInfo{}, huma.Error401Unauthorized(err.Error())
}
accessToken := strings.TrimSpace(string(accessTokenBytes))

// check the access token against the KBase auth server
// and fetch the first ORCID associated with it
// and return info about the corresponding user
authServer, err := auth.NewKBaseAuthServer(accessToken)
var orcid string
var orcids []string
if err == nil {
orcids, err = authServer.Orcids()
if err == nil {
orcid = orcids[0]
}
if err != nil {
return auth.UserInfo{}, huma.Error401Unauthorized(err.Error())
}
userInfo, err := authServer.UserInfo()
if err != nil {
return orcid, huma.Error401Unauthorized(err.Error())
return userInfo, huma.Error401Unauthorized(err.Error())
}
return orcid, nil
return userInfo, nil
}

type ServiceInfoOutput struct {
Expand Down Expand Up @@ -252,7 +249,7 @@ func (service *prototype) getDatabaseSearchParameters(ctx context.Context,
Database string `path:"db" example:"jdp" doc:"the abbreviated name of a database"`
}) (*SearchParametersOutput, error) {

orcid, err := authorize(input.Authorization)
userInfo, err := authorize(input.Authorization)
if err != nil {
return nil, err
}
Expand All @@ -262,7 +259,7 @@ func (service *prototype) getDatabaseSearchParameters(ctx context.Context,
if !ok {
return nil, fmt.Errorf("Database %s not found", input.Database)
}
db, err := databases.NewDatabase(orcid, input.Database)
db, err := databases.NewDatabase(userInfo.Orcid, input.Database)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -297,7 +294,7 @@ func searchDatabase(ctx context.Context,
input *SearchDatabaseInput,
specific map[string]json.RawMessage) (*SearchResultsOutput, error) {

orcid, err := authorize(input.Authorization)
userInfo, err := authorize(input.Authorization)
if err != nil {
return nil, err
}
Expand All @@ -322,7 +319,7 @@ func searchDatabase(ctx context.Context,
}

slog.Info(fmt.Sprintf("Searching database %s for files...", input.Database))
db, err := databases.NewDatabase(orcid, input.Database)
db, err := databases.NewDatabase(userInfo.Orcid, input.Database)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -403,7 +400,7 @@ func (service *prototype) fetchFileMetadata(ctx context.Context,
Limit int `json:"limit" query:"limit" example:"50" doc:"Limits the number of metadata records returned"`
}) (*FileMetadataOutput, error) {

orcid, err := authorize(input.Authorization)
userInfo, err := authorize(input.Authorization)
if err != nil {
return nil, err
}
Expand All @@ -422,7 +419,7 @@ func (service *prototype) fetchFileMetadata(ctx context.Context,

slog.Info(fmt.Sprintf("Fetching file metadata for %d files in database %s...",
len(ids), input.Database))
db, err := databases.NewDatabase(orcid, input.Database)
db, err := databases.NewDatabase(userInfo.Orcid, input.Database)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -453,13 +450,19 @@ func (service *prototype) createTransfer(ctx context.Context,
ContentType string `header:"Content-Type" doc:"Content-Type header (must be application/json)"`
}) (*TransferOutput, error) {

orcid, err := authorize(input.Authorization)
userInfo, err := authorize(input.Authorization)
if err != nil {
return nil, err
}

taskId, err := tasks.Create(orcid, input.Body.Source,
input.Body.Destination, input.Body.FileIds)
taskId, err := tasks.Create(tasks.Specification{
UserInfo: userInfo,
Source: input.Body.Source,
Destination: input.Body.Destination,
FileIds: input.Body.FileIds,
Description: input.Body.Description,
Instructions: input.Body.Instructions,
})
if err != nil {
return nil, err
}
Expand Down
5 changes: 5 additions & 0 deletions services/transfer_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package services

import (
"context"
"encoding/json"

"github.com/google/uuid"

Expand Down Expand Up @@ -59,6 +60,10 @@ type TransferRequest struct {
FileIds []string `json:"file_ids" example:"[\"fileid1\", \"fileid2\"]" doc:"source-specific identifiers for files to be transferred"`
// name of destination database
Destination string `json:"destination" example:"kbase" doc:"destination database identifier"`
// a Markdown description of the transfer request
Description string `json:"description,omitempty" example:"# title\n* type: assembly\n" doc:"Markdown task description"`
// machine-readable instructions for processing a payload at the destination site
Instructions json.RawMessage `json:"instructions,omitempty" doc:"JSON object containing machine-readable instructions for processing payload at destination"`
}

// a response for a file transfer request (POST)
Expand Down
Loading
Loading