diff --git a/cmd/registry_serve.go b/cmd/registry_serve.go index 0e4eee5bf..6f8977b14 100644 --- a/cmd/registry_serve.go +++ b/cmd/registry_serve.go @@ -67,9 +67,12 @@ func serveRegistry( /*cmd*/ *cobra.Command /*args*/, []string) error { } rootRouterGroup := engine.Group("/") - // Call out to registry to establish routes for the gin engine - registry.RegisterRegistryRoutes(rootRouterGroup) - registry.RegisterRegistryWebAPI(rootRouterGroup) + // Register routes for server/Pelican client facing APIs + registry.RegisterRegistryAPI(rootRouterGroup) + // Register routes for APIs to registry Web UI + if err := registry.RegisterRegistryWebAPI(rootRouterGroup); err != nil { + return err + } log.Info("Starting web engine...") // Might need to play around with this setting more to handle diff --git a/config/config.go b/config/config.go index a5ab56de1..b31bd77d0 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,7 @@ import ( "syscall" "time" + "github.com/go-playground/validator/v10" "github.com/pelicanplatform/pelican/param" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -117,11 +118,18 @@ var ( transport *http.Transport onceTransport sync.Once + // Global struct validator + validate *validator.Validate + // A variable indicating enabled Pelican servers in the current process enabledServers ServerType setServerOnce sync.Once ) +func init() { + validate = validator.New(validator.WithRequiredStructEnabled()) +} + // Set sets a list of newServers to ServerType instance func (sType *ServerType) Set(newServers []ServerType) { for _, server := range newServers { @@ -416,6 +424,11 @@ func GetTransport() *http.Transport { return transport } +// Get singleton global validte method for field validation +func GetValidate() *validator.Validate { + return validate +} + func InitConfig() { viper.SetConfigType("yaml") // 1) Set up defaults.yaml @@ -534,6 +547,7 @@ func InitServer(enabledServers []ServerType, currentServer ServerType) error { viper.SetDefault("IssuerKey", filepath.Join(configDir, "issuer.jwk")) viper.SetDefault("Server.UIPasswordFile", filepath.Join(configDir, "server-web-passwd")) viper.SetDefault("Server.UIActivationCodeFile", filepath.Join(configDir, "server-web-activation-code")) + viper.SetDefault("Server.SessionSecretFile", filepath.Join(configDir, "session-secret")) viper.SetDefault("OIDC.ClientIDFile", filepath.Join(configDir, "oidc-client-id")) viper.SetDefault("OIDC.ClientSecretFile", filepath.Join(configDir, "oidc-client-secret")) viper.SetDefault("Cache.ExportLocation", "/") @@ -607,8 +621,6 @@ func InitServer(enabledServers []ServerType, currentServer ServerType) error { return errors.Wrap(err, fmt.Sprint("Invalid Server.ExternalWebUrl: ", externalAddressStr)) } - setupTransport() - tokenRefreshInterval := param.Monitoring_TokenRefreshInterval.GetDuration() tokenExpiresIn := param.Monitoring_TokenExpiresIn.GetDuration() @@ -639,15 +651,18 @@ func InitServer(enabledServers []ServerType, currentServer ServerType) error { return err } - // Generate the session cookie secret and save it as the default value - err = GenerateSessionSecret() - if err != nil { + // Generate the session secret and save it as the default value + if err := GenerateSessionSecret(); err != nil { return err } // After we know we have the certs we need, call setupTransport (which uses those certs for its TLSConfig) setupTransport() + // Setup CSRF middleware. To use it, you need to add this middleware to your chain + // of http handlers by calling config.GetCSRFHandler() + setupCSRFHandler() + // Set up the server's issuer URL so we can access that data wherever we need to find keys and whatnot // This populates Server.IssuerUrl, and can be safely fetched using server_utils.GetServerIssuerURL() err = parseServerIssuerURL(currentServer) diff --git a/config/csrf.go b/config/csrf.go new file mode 100644 index 000000000..2885c8559 --- /dev/null +++ b/config/csrf.go @@ -0,0 +1,66 @@ +/*************************************************************** + * + * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research + * + * 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 config + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "github.com/gorilla/csrf" + adapter "github.com/gwatts/gin-adapter" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +var ( + // Global CSRF handler that shares the same auth key + csrfHanlder gin.HandlerFunc + onceCSRFHanlder sync.Once +) + +func setupCSRFHandler() { + csrfKey, err := LoadSessionSecret() + if err != nil { + log.Error("Error loading session secret, abort setting up CSRF handler:", err) + return + } + CSRF := csrf.Protect(csrfKey, + csrf.SameSite(csrf.SameSiteStrictMode), + csrf.Path("/"), + csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, err := w.Write([]byte(`{"message": "CSRF token invalid"}`)) + if err != nil { + log.Error("Error writing error message back as response") + } + })), + ) + csrfHanlder = adapter.Wrap(CSRF) +} + +func GetCSRFHandler() (gin.HandlerFunc, error) { + onceCSRFHanlder.Do(func() { + setupCSRFHandler() + }) + if csrfHanlder == nil { + return nil, errors.New("Error setting up the CSRF hanlder") + } + return csrfHanlder, nil +} diff --git a/config/init_server_creds.go b/config/init_server_creds.go index 0b30d4c73..60ca2227a 100644 --- a/config/init_server_creds.go +++ b/config/init_server_creds.go @@ -624,12 +624,19 @@ func GenerateSessionSecret() error { return nil } +// Load session secret from Server_SessionSecretFile. Generate session secret +// if no file present. func LoadSessionSecret() ([]byte, error) { secretLocation := param.Server_SessionSecretFile.GetString() if secretLocation == "" { return []byte{}, errors.New("Empty filename for Server_SessionSecretFile") } + + if err := GenerateSessionSecret(); err != nil { + return []byte{}, err + } + rest, err := os.ReadFile(secretLocation) if err != nil { return []byte{}, errors.Wrap(err, "Error reading secret file") diff --git a/docs/parameters.yaml b/docs/parameters.yaml index e42ddd0db..12cdfbfe1 100644 --- a/docs/parameters.yaml +++ b/docs/parameters.yaml @@ -536,6 +536,33 @@ type: bool default: true components: ["nsregistry"] --- +name: Registry.AdminUsers +description: >- + A string slice of "subject" claim of users to give admin permission for registry UI. + + The "subject" claim should be the "CILogon User Identifier" from CILogon user page: https://cilogon.org/ +type: stringSlice +default: [] +components: ["nsregistry"] +--- +name: Registry.Institutions +description: >- + A array of institution objects available to register. Users can only select from this list + when they register a new namespace. Each object has `name` and `id` field where + `name` is a human-readable name for the institution and `id` is a unique identifier + for the institution. For Pelican running in OSDF alias, the `id` will be OSG ID. + + For example: + + ``` + - name: University of Wisconsin - Madison + id: https://osg-htc.org/iid/01y2jtd41 + ``` + +type: object +default: none +components: ["nsregistry"] +--- ############################ # Server-level configs # ############################ @@ -648,7 +675,7 @@ components: ["origin", "director", "nsregistry"] name: Server.IssuerJwks description: >- A filepath indicating where the server's public JSON web keyset can be found. -type: string +type: filename default: none components: ["origin", "director", "nsregistry"] --- @@ -671,7 +698,8 @@ name: Server.SessionSecretFile description: >- The filepath to the secret for encrypt/decrypt session data for Pelican web UI to initiate a session cookie - This is only used for sending redirect request for OAuth2 authentication flow as of 11/22/2023 + This is used for sending redirect request for OAuth2 authentication follow. + This is also used for CSRF auth key. type: filename default: $ConfigBase/session-secret The default content of the file is the hash of the concatenation of "pelican" and the DER form of ${IssuerKey} diff --git a/go.mod b/go.mod index d1e849096..4c3991118 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,9 @@ require ( github.com/go-ini/ini v1.67.0 github.com/go-kit/log v0.2.1 github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/gorilla/csrf v1.7.2 github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd + github.com/gwatts/gin-adapter v1.0.0 github.com/hashicorp/go-version v1.6.0 github.com/jellydator/ttlcache/v3 v3.1.0 github.com/jsipprell/keyctl v1.0.4-0.20211208153515-36ca02672b6c @@ -48,7 +50,7 @@ require ( require ( github.com/gorilla/context v1.1.1 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.2.1 // indirect ) @@ -91,7 +93,7 @@ require ( github.com/go-openapi/validate v0.22.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-playground/validator/v10 v10.16.0 github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect diff --git a/go.sum b/go.sum index 0d846d513..5fd739c8b 100644 --- a/go.sum +++ b/go.sum @@ -208,8 +208,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= +github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg= @@ -320,13 +320,22 @@ github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 github.com/gophercloud/gophercloud v1.5.0 h1:cDN6XFCLKiiqvYpjQLq9AiM7RDRbIC9450WpPH+yvXo= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= +github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= +github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd h1:PpuIBO5P3e9hpqBD0O/HjhShYuM6XE0i/lbE6J94kww= github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A= +github.com/gwatts/gin-adapter v1.0.0 h1:TsmmhYTR79/RMTsfYJ2IQvI1F5KZ3ZFJxuQSYEOpyIA= +github.com/gwatts/gin-adapter v1.0.0/go.mod h1:44AEV+938HsS0mjfXtBDCUZS9vONlF2gwvh8wu4sRYc= github.com/hashicorp/consul/api v1.22.0 h1:ydEvDooB/A0c/xpsBd8GSt7P2/zYPBui4KrNip0xGjE= github.com/hashicorp/cronexpr v1.1.2 h1:wG/ZYIKT+RT3QkOdgYc+xsKWVRgnxJ1OJtjjy84fJ9A= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= diff --git a/param/parameters.go b/param/parameters.go index a2eb5aaf0..25a79512e 100644 --- a/param/parameters.go +++ b/param/parameters.go @@ -141,6 +141,7 @@ var ( Issuer_GroupRequirements = StringSliceParam{"Issuer.GroupRequirements"} Monitoring_AggregatePrefixes = StringSliceParam{"Monitoring.AggregatePrefixes"} Origin_ScitokensRestrictedPaths = StringSliceParam{"Origin.ScitokensRestrictedPaths"} + Registry_AdminUsers = StringSliceParam{"Registry.AdminUsers"} ) var ( @@ -194,4 +195,5 @@ var ( var ( Issuer_AuthorizationTemplates = ObjectParam{"Issuer.AuthorizationTemplates"} Issuer_OIDCAuthenticationRequirements = ObjectParam{"Issuer.OIDCAuthenticationRequirements"} + Registry_Institutions = ObjectParam{"Registry.Institutions"} ) diff --git a/param/parameters_struct.go b/param/parameters_struct.go index 87c44f5a2..5de2fe592 100644 --- a/param/parameters_struct.go +++ b/param/parameters_struct.go @@ -109,7 +109,9 @@ type config struct { Token string } Registry struct { + AdminUsers []string DbLocation string + Institutions interface{} RequireKeyChaining bool } Server struct { diff --git a/registry/client_commands.go b/registry/client_commands.go index 27bcf849d..9993368dc 100644 --- a/registry/client_commands.go +++ b/registry/client_commands.go @@ -189,6 +189,11 @@ func NamespaceRegister(privateKey jwk.Key, namespaceRegistryEndpoint string, acc return errors.Wrapf(err, "Failed to make request: %v", respData.Error) } fmt.Println(respData.Message) + } else { + if err != nil { + return errors.Wrapf(err, "Failed to make request: %s", resp) + } + return errors.Wrapf(unmarshalErr, "Failed to unmarshall request response: %v", respData.Error) } return nil diff --git a/registry/client_commands_test.go b/registry/client_commands_test.go index 785ac3b40..fdcdd6b99 100644 --- a/registry/client_commands_test.go +++ b/registry/client_commands_test.go @@ -41,14 +41,13 @@ func registryMockup(t *testing.T, testName string) *httptest.Server { err := config.InitServer([]config.ServerType{config.RegistryType}, config.RegistryType) require.NoError(t, err) - err = InitializeDB() - require.NoError(t, err) + setupMockRegistryDB(t) gin.SetMode(gin.TestMode) engine := gin.Default() //Configure registry - RegisterRegistryRoutes(engine.Group("/")) + RegisterRegistryAPI(engine.Group("/")) //Set up a server to use for testing svr := httptest.NewServer(engine) diff --git a/registry/registry.go b/registry/registry.go index 8c159a24d..81db57db1 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -80,10 +80,6 @@ type Response struct { DeviceCode string `json:"device_code"` } -type AdminJSON struct { - AdminApproved bool `json:"admin_approved"` -} - type TokenResponse struct { AccessToken string `json:"access_token"` Error string `json:"error"` @@ -115,7 +111,7 @@ func matchKeys(incomingKey jwk.Key, registeredNamespaces []string) (bool, error) // permitting the action (assuming their keys haven't been stolen!) foundMatch := false for _, ns := range registeredNamespaces { - keyset, err := dbGetPrefixJwks(ns, false) + keyset, err := getNamespaceJwksByPrefix(ns, false) if err != nil { return false, errors.Wrapf(err, "Cannot get keyset for %s from the database", ns) } @@ -229,31 +225,10 @@ func keySignChallengeInit(ctx *gin.Context, data *registrationData) error { } func keySignChallengeCommit(ctx *gin.Context, data *registrationData, action string) error { - // Parse the client's jwks as a set here - clientJwks, err := jwk.Parse(data.Pubkey) + // Validate the client's jwks as a set here + key, err := validateJwks(string(data.Pubkey)) if err != nil { - return errors.Wrap(err, "Couldn't parse the pubkey from the client") - } - - if log.IsLevelEnabled(log.DebugLevel) { - // Let's check that we can convert to JSON and get the right thing... - jsonbuf, err := json.Marshal(clientJwks) - if err != nil { - return errors.Wrap(err, "failed to marshal the client's keyset into JSON") - } - log.Debugln("Client JWKS as seen by the registry server:", string(jsonbuf)) - } - - /* - * TODO: This section makes the assumption that the incoming jwks only contains a single - * key, a property that is enforced by the client at the origin. Eventually we need - * to support the addition of other keys in the jwks stored for the origin. There is - * a similar TODO listed in client_commands.go, as the choices made there mirror the - * choices made here. - */ - key, exists := clientJwks.Key(0) - if !exists { - return errors.New("There was no key at index 0 in the client's JWKS. Something is wrong") + return err } var rawkey interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey @@ -307,57 +282,7 @@ func keySignChallengeCommit(ctx *gin.Context, data *registrationData, action str return nil } - if param.Registry_RequireKeyChaining.GetBool() { - superspaces, subspaces, inTopo, err := namespaceSupSubChecks(data.Prefix) - if err != nil { - log.Errorf("Failed to check if namespace suffixes or prefixes another registered namespace: %v", err) - return errors.Wrap(err, "Server encountered an error checking if namespace already exists") - } - - // if not in OSDF mode, this will be false - if inTopo { - _ = ctx.AbortWithError(403, errors.New("Cannot register a super or subspace of a namespace already registered in topology")) - return errors.New("Cannot register a super or subspace of a namespace already registered in topology") - } - // If we make the assumption that namespace prefixes are heirarchical, eg that the owner of /foo should own - // everything under /foo (/foo/bar, /foo/baz, etc), then it makes sense to check for superspaces first. If any - // superspace is found, they logically "own" the incoming namespace. - if len(superspaces) > 0 { - // If this is the case, we want to make sure that at least one of the superspaces has the - // same registration key as the incoming. This guarantees the owner of the superspace is - // permitting the action (assuming their keys haven't been stolen!) - matched, err := matchKeys(key, superspaces) - if err != nil { - ctx.JSON(500, gin.H{"error": "Server encountered an error checking for key matches in the database"}) - return errors.Errorf("%v: Unable to check if the incoming key for %s matched any public keys for %s", err, data.Prefix, subspaces) - } - if !matched { - _ = ctx.AbortWithError(403, errors.New("Cannot register a namespace that is suffixed or prefixed by an already-registered namespace unless the incoming public key matches a registered key")) - return errors.New("Cannot register a namespace that is suffixed or prefixed by an already-registered namespace unless the incoming public key matches a registered key") - } - - } else if len(subspaces) > 0 { - // If there are no superspaces, we can check the subspaces. - - // TODO: Eventually we might want to check only the highest level subspaces and use those keys for matching. For example, - // if /foo/bar and /foo/bar/baz are registered with two keysets such that the complement of their intersections is not null, - // it may be the case that the only key we match against belongs to /foo/bar/baz. If we go ahead with registration at that - // point, we're essentially saying /foo/bar/baz, the logical subspace of /foo/bar, has authorized a superspace for both. - // More interestingly, if /foo/bar and /foo/baz are both registered, should they both be consulted before adding /foo? - - // For now, we'll just check for any key match. - matched, err := matchKeys(key, subspaces) - if err != nil { - ctx.JSON(500, gin.H{"error": "Server encountered an error checking for key matches in the database"}) - return errors.Errorf("%v: Unable to check if the incoming key for %s matched any public keys for %s", err, data.Prefix, subspaces) - } - if !matched { - _ = ctx.AbortWithError(403, errors.New("Cannot register a namespace that is suffixed or prefixed by an already-registered namespace unless the incoming public key matches a registered key")) - return errors.New("Cannot register a namespace that is suffixed or prefixed by an already-registered namespace unless the incoming public key matches a registered key") - } - } - } - reqPrefix, err := validateNSPath(data.Prefix) + reqPrefix, err := validatePrefix(data.Prefix) if err != nil { err = errors.Wrapf(err, "Requested namespace %s failed validation", reqPrefix) log.Errorln(err) @@ -365,6 +290,18 @@ func keySignChallengeCommit(ctx *gin.Context, data *registrationData, action str } data.Prefix = reqPrefix + valErr, sysErr := validateKeyChaining(reqPrefix, key) + if valErr != nil { + log.Errorln(err) + ctx.JSON(http.StatusForbidden, gin.H{"error": valErr}) + return valErr + } + if sysErr != nil { + log.Errorln(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": sysErr}) + return sysErr + } + err = dbAddNamespace(ctx, data) if err != nil { ctx.JSON(500, gin.H{"error": "The server encountered an error while attempting to add the prefix to its database"}) @@ -380,42 +317,6 @@ func keySignChallengeCommit(ctx *gin.Context, data *registrationData, action str return nil } -func validateNSPath(nspath string) (string, error) { - if len(nspath) == 0 { - return "", errors.New("Path prefix may not be empty") - } - if nspath[0] != '/' { - return "", errors.New("Path prefix must be absolute - relative paths are not allowed") - } - components := strings.Split(nspath, "/")[1:] - if len(components) == 0 { - return "", errors.New("Cannot register the prefix '/' for an origin") - } else if components[0] == "api" { - return "", errors.New("Cannot register a prefix starting with '/api'") - } else if components[0] == "view" { - return "", errors.New("Cannot register a prefix starting with '/view'") - } else if components[0] == "pelican" { - return "", errors.New("Cannot register a prefix starting with '/pelican'") - } - result := "" - for _, component := range components { - if len(component) == 0 { - continue - } else if component == "." { - return "", errors.New("Path component cannot be '.'") - } else if component == ".." { - return "", errors.New("Path component cannot be '..'") - } else if component[0] == '.' { - return "", errors.New("Path component cannot begin with a '.'") - } - result += "/" + component - } - if result == "/" || len(result) == 0 { - return "", errors.New("Cannot register the prefix '/' for an origin") - } - return result, nil -} - /* Handler functions called upon by the gin router */ @@ -603,15 +504,8 @@ func dbAddNamespace(ctx *gin.Context, data *registrationData) error { ns.Identity = data.Identity } - //All caches added will not be approved (also false for origins, but that's fine as it doesn't check for origins) - jResult, err := json.Marshal(AdminJSON{ - AdminApproved: false, - }) - - if err != nil { - return errors.Wrapf(err, "Failure to unmarshal json struct") - } - ns.AdminMetadata = string(jResult) + // Overwrite status to Pending to filter malicious request + ns.AdminMetadata.Status = Pending err = addNamespace(&ns) if err != nil { @@ -654,7 +548,7 @@ func dbDeleteNamespace(ctx *gin.Context) { delTokenStr := strings.TrimPrefix(authHeader, "Bearer ") // Have the token, now we need to load the JWKS for the prefix - originJwks, err := dbGetPrefixJwks(prefix, false) + originJwks, err := getNamespaceJwksByPrefix(prefix, false) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "server encountered an error loading the prefix's stored jwks"}) log.Errorf("Failed to get prefix's stored jwks: %v", err) @@ -745,9 +639,7 @@ func metadataHandler(ctx *gin.Context) { if filepath.Base(path) == "issuer.jwks" { // do something prefix := strings.TrimSuffix(path, "/.well-known/issuer.jwks") - - jwks, err := dbGetPrefixJwks(prefix, true) - + jwks, err := getNamespaceJwksByPrefix(prefix, true) if err != nil { if err == serverCredsErr { ctx.JSON(404, gin.H{"error": "cache has not been approved by federation administrator"}) @@ -777,7 +669,7 @@ func metadataHandler(ctx *gin.Context) { func dbGetNamespace(ctx *gin.Context) { prefix := ctx.GetHeader("X-Pelican-Prefix") - ns, err := getNamespace(prefix) + ns, err := getNamespaceByPrefix(prefix) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -802,7 +694,7 @@ func getOpenIDConfiguration(c *gin.Context) { } */ -func RegisterRegistryRoutes(router *gin.RouterGroup) { +func RegisterRegistryAPI(router *gin.RouterGroup) { v1registry := router.Group("/api/v1.0/registry") { v1registry.POST("", cliRegisterNamespace) diff --git a/registry/registry_db.go b/registry/registry_db.go index c50520541..bb64c3c1a 100644 --- a/registry/registry_db.go +++ b/registry/registry_db.go @@ -40,20 +40,50 @@ import ( "github.com/pelicanplatform/pelican/utils" ) +type RegistrationStatus string + +// The AdminMetadata is used in [Namespace] as a marshalled JSON string +// to be stored in registry DB. +// +// The *UserID are meant to correspond to the "sub" claim of the user token that +// the OAuth client issues if the user is logged in using OAuth, or it should be +// "admin" from local password-based authentication. +// +// To prevent users from writing to certain fields (readonly), you may use "post" tag +// with value "exclude". This will exclude the field from user's create/update requests +// and the field will also be excluded from field discovery endpoint (OPTION method). +// +// We use validator package to validate struct fields from user requests. If a field is +// required, add `validate:"required"` to that field. This tag will also be used by fields discovery +// endpoint to tell the UI if a field is required. For other validator tags, +// visit: https://pkg.go.dev/github.com/go-playground/validator/v10 +type AdminMetadata struct { + UserID string `json:"user_id" post:"exclude"` // "sub" claim of user JWT who requested registration + Description string `json:"description"` + SiteName string `json:"site_name"` + Institution string `json:"institution" validate:"required"` // the unique identifier of the institution + SecurityContactUserID string `json:"security_contact_user_id"` // "sub" claim of user who is responsible for taking security concern + Status RegistrationStatus `json:"status" post:"exclude"` + ApproverID string `json:"approver_id" post:"exclude"` // "sub" claim of user JWT who approved registration + ApprovedAt time.Time `json:"approved_at" post:"exclude"` + CreatedAt time.Time `json:"created_at" post:"exclude"` + UpdatedAt time.Time `json:"updated_at" post:"exclude"` +} + type Namespace struct { - ID int `json:"id"` - Prefix string `json:"prefix"` - Pubkey string `json:"pubkey"` - Identity string `json:"identity"` - AdminMetadata string `json:"admin_metadata"` + ID int `json:"id" post:"exclude"` + Prefix string `json:"prefix" validate:"required"` + Pubkey string `json:"pubkey" validate:"required"` + Identity string `json:"identity" post:"exclude"` + AdminMetadata AdminMetadata `json:"admin_metadata"` } type NamespaceWOPubkey struct { - ID int `json:"id"` - Prefix string `json:"prefix"` - Pubkey string `json:"-"` // Don't include pubkey in this case - Identity string `json:"identity"` - AdminMetadata string `json:"admin_metadata"` + ID int `json:"id"` + Prefix string `json:"prefix"` + Pubkey string `json:"-"` // Don't include pubkey in this case + Identity string `json:"identity"` + AdminMetadata AdminMetadata `json:"admin_metadata"` } type ServerType string @@ -63,6 +93,13 @@ const ( CacheType ServerType = "cache" ) +const ( + Pending RegistrationStatus = "Pending" + Approved RegistrationStatus = "Approved" + Denied RegistrationStatus = "Denied" + Unknown RegistrationStatus = "Unknown" +) + /* Declare the DB handle as an unexported global so that all functions in the package can access it without having to @@ -73,6 +110,14 @@ https://www.alexedwards.net/blog/organising-database-access */ var db *sql.DB +func (st ServerType) String() string { + return string(st) +} + +func (rs RegistrationStatus) String() string { + return string(rs) +} + func createNamespaceTable() { //We put a size limit on admin_metadata to guard against potentially future //malicious large inserts @@ -212,7 +257,36 @@ func namespaceExistsById(id int) (bool, error) { return found, nil } -func getPrefixJwksById(id int) (jwk.Set, error) { +func namespaceBelongsToUserId(id int, userId string) (bool, error) { + query := `SELECT admin_metadata FROM namespace where id = ?` + rows, err := db.Query(query, id) + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + ns := &Namespace{} + adminMetadataStr := "" + if err := rows.Scan(&adminMetadataStr); err != nil { + return false, err + } + // For backward compatibility, if adminMetadata is an empty string, don't unmarshall json + if adminMetadataStr != "" { + if err := json.Unmarshal([]byte(adminMetadataStr), &ns.AdminMetadata); err != nil { + return false, err + } + } else { + return false, nil // If adminMetadata is an empty string, no userId is present + } + if ns.AdminMetadata.UserID == userId { + return true, nil + } + } + return false, nil +} + +func getNamespaceJwksById(id int) (jwk.Set, error) { jwksQuery := `SELECT pubkey FROM namespace WHERE id = ?` var pubkeyStr string err := db.QueryRow(jwksQuery, id).Scan(&pubkeyStr) @@ -231,25 +305,28 @@ func getPrefixJwksById(id int) (jwk.Set, error) { return set, nil } -func dbGetPrefixJwks(prefix string, approvalRequired bool) (*jwk.Set, error) { +func getNamespaceJwksByPrefix(prefix string, approvalRequired bool) (*jwk.Set, error) { var jwksQuery string var pubkeyStr string if strings.HasPrefix(prefix, "/caches/") && approvalRequired { - var admin_metadata string + adminMetadataStr := "" jwksQuery = `SELECT pubkey, admin_metadata FROM namespace WHERE prefix = ?` - err := db.QueryRow(jwksQuery, prefix).Scan(&pubkeyStr, &admin_metadata) + err := db.QueryRow(jwksQuery, prefix).Scan(&pubkeyStr, &adminMetadataStr) if err != nil { if err == sql.ErrNoRows { return nil, errors.New("prefix not found in database") } return nil, errors.Wrap(err, "error performing cache pubkey query") } - - var adminData AdminJSON - err = json.Unmarshal([]byte(admin_metadata), &adminData) - - if !adminData.AdminApproved || err != nil { - return nil, serverCredsErr + if adminMetadataStr != "" { // Older version didn't have admin_metadata populated, skip checking + adminMetadata := AdminMetadata{} + if err = json.Unmarshal([]byte(adminMetadataStr), &adminMetadata); err != nil { + return nil, errors.Wrap(err, "Failed to unmarshall admin_metadata") + } + // TODO: Move this to upper functions that handles business logic to keep db access functions simple + if adminMetadata.Status != Approved { + return nil, serverCredsErr + } } } else { jwksQuery := `SELECT pubkey FROM namespace WHERE prefix = ?` @@ -270,18 +347,140 @@ func dbGetPrefixJwks(prefix string, approvalRequired bool) (*jwk.Set, error) { return &set, nil } +func getNamespaceById(id int) (*Namespace, error) { + if id < 1 { + return nil, errors.New("Invalid id. id must be a positive number") + } + ns := &Namespace{} + adminMetadataStr := "" + query := `SELECT id, prefix, pubkey, identity, admin_metadata FROM namespace WHERE id = ?` + err := db.QueryRow(query, id).Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &adminMetadataStr) + if err != nil { + return nil, err + } + // For backward compatibility, if adminMetadata is an empty string, don't unmarshall json + if adminMetadataStr != "" { + if err := json.Unmarshal([]byte(adminMetadataStr), &ns.AdminMetadata); err != nil { + return nil, err + } + } + return ns, nil +} + +func getNamespaceByPrefix(prefix string) (*Namespace, error) { + if prefix == "" { + return nil, errors.New("Invalid prefix. Prefix must not be empty") + } + ns := &Namespace{} + adminMetadataStr := "" + query := `SELECT id, prefix, pubkey, identity, admin_metadata FROM namespace WHERE prefix = ?` + err := db.QueryRow(query, prefix).Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &adminMetadataStr) + if err != nil { + return nil, err + } + // For backward compatibility, if adminMetadata is an empty string, don't unmarshall json + if adminMetadataStr != "" { + if err := json.Unmarshal([]byte(adminMetadataStr), &ns.AdminMetadata); err != nil { + return nil, err + } + } + return ns, nil +} + +// Get a collection of namespaces by [Namespace.AdminMetadata.UserID] +func getNamespacesByUserID(userID string) ([]*Namespace, error) { + query := `SELECT id, prefix, pubkey, identity, admin_metadata FROM namespace ORDER BY id ASC` + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + namespaces := make([]*Namespace, 0) + for rows.Next() { + ns := &Namespace{} + adminMetadataStr := "" + if err := rows.Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &adminMetadataStr); err != nil { + return nil, err + } + // For backward compatibility, if adminMetadata is an empty string, don't unmarshall json + if adminMetadataStr != "" { + if err := json.Unmarshal([]byte(adminMetadataStr), &ns.AdminMetadata); err != nil { + return nil, err + } + } + if ns.AdminMetadata.UserID == userID { + namespaces = append(namespaces, ns) + } + } + return namespaces, nil +} + +func getNamespacesByServerType(serverType ServerType) ([]*Namespace, error) { + query := "" + if serverType == CacheType { + // Refer to the cache prefix name in cmd/cache_serve + query = `SELECT id, prefix, pubkey, identity, admin_metadata FROM NAMESPACE WHERE PREFIX LIKE '/caches/%' ORDER BY id ASC` + } else if serverType == OriginType { + query = `SELECT id, prefix, pubkey, identity, admin_metadata FROM NAMESPACE WHERE NOT PREFIX LIKE '/caches/%' ORDER BY id ASC` + } else { + return nil, errors.New(fmt.Sprint("Can't get namespace: unsupported server type: ", serverType)) + } + + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + namespaces := make([]*Namespace, 0) + for rows.Next() { + ns := &Namespace{} + adminMetadataStr := "" + if err := rows.Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &adminMetadataStr); err != nil { + return nil, err + } + // For backward compatibility, if adminMetadata is an empty string, don't unmarshall json + if adminMetadataStr != "" { + if err := json.Unmarshal([]byte(adminMetadataStr), &ns.AdminMetadata); err != nil { + return nil, err + } + } + namespaces = append(namespaces, ns) + } + + return namespaces, nil +} + /* Some generic functions for CRUD actions on namespaces, used BY the registry (as opposed to the parallel functions) used by the client. */ + func addNamespace(ns *Namespace) error { query := `INSERT INTO namespace (prefix, pubkey, identity, admin_metadata) VALUES (?, ?, ?, ?)` tx, err := db.Begin() if err != nil { return err } - _, err = tx.Exec(query, ns.Prefix, ns.Pubkey, ns.Identity, ns.AdminMetadata) + + // Adding default values to the field. Note that you need to pass other fields + // including user_id before this function + ns.AdminMetadata.CreatedAt = time.Now() + ns.AdminMetadata.UpdatedAt = time.Now() + // We only set status to pending when it's empty to allow tests to add a namespace with + // desired status + if ns.AdminMetadata.Status == "" { + ns.AdminMetadata.Status = Pending + } + + strAdminMetadata, err := json.Marshal(ns.AdminMetadata) + if err != nil { + return errors.Wrap(err, "Fail to marshall AdminMetadata") + } + + _, err = tx.Exec(query, ns.Prefix, ns.Pubkey, ns.Identity, strAdminMetadata) if err != nil { if errRoll := tx.Rollback(); errRoll != nil { log.Errorln("Failed to rollback transaction:", errRoll) @@ -291,72 +490,98 @@ func addNamespace(ns *Namespace) error { return tx.Commit() } -/** - * Commenting this out until we are ready to use it. -BB func updateNamespace(ns *Namespace) error { - query := `UPDATE namespace SET pubkey = ?, identity = ?, admin_metadata = ? WHERE prefix = ?` - _, err := db.Exec(query, ns.Pubkey, ns.Identity, ns.AdminMetadata, ns.Prefix) - return err -} -*/ + existingNs, err := getNamespaceById(ns.ID) + if err != nil || existingNs == nil { + return errors.Wrap(err, "Failed to get namespace") + } + existingNsAdmin := existingNs.AdminMetadata + // We prevent the following fields from being modified by the user for now. + // They are meant for "internal" use only and we don't support changing + // UserID on the fly. We also don't allow changing Status other than explicitly + // call updateNamespaceStatusById + ns.AdminMetadata.UserID = existingNsAdmin.UserID + ns.AdminMetadata.CreatedAt = existingNsAdmin.CreatedAt + ns.AdminMetadata.Status = existingNsAdmin.Status + ns.AdminMetadata.ApprovedAt = existingNsAdmin.ApprovedAt + ns.AdminMetadata.ApproverID = existingNsAdmin.ApproverID + ns.AdminMetadata.UpdatedAt = time.Now() + strAdminMetadata, err := json.Marshal(ns.AdminMetadata) + if err != nil { + return errors.Wrap(err, "Fail to marshall AdminMetadata") + } -func deleteNamespace(prefix string) error { - deleteQuery := `DELETE FROM namespace WHERE prefix = ?` + // We intentionally exclude updating "identity" as this should only be updated + // when user registered through Pelican client with identity + query := `UPDATE namespace SET pubkey = ?, admin_metadata = ? WHERE id = ?` tx, err := db.Begin() if err != nil { return err } - _, err = db.Exec(deleteQuery, prefix) + _, err = tx.Exec(query, ns.Pubkey, strAdminMetadata, ns.ID) if err != nil { if errRoll := tx.Rollback(); errRoll != nil { log.Errorln("Failed to rollback transaction:", errRoll) } - return errors.Wrap(err, "Failed to execute deletion query") + return errors.Wrap(err, "Failed to execute update query") } return tx.Commit() } -func getNamespace(prefix string) (*Namespace, error) { - ns := &Namespace{} - query := `SELECT * FROM namespace WHERE prefix = ?` - err := db.QueryRow(query, prefix).Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &ns.AdminMetadata) +func updateNamespaceStatusById(id int, status RegistrationStatus, approverId string) error { + ns, err := getNamespaceById(id) if err != nil { - return nil, err + return errors.Wrap(err, "Error getting namespace by id") } - return ns, nil -} -func getAllNamespaces() ([]*Namespace, error) { - query := `SELECT * FROM namespace` - rows, err := db.Query(query) + ns.AdminMetadata.Status = status + ns.AdminMetadata.UpdatedAt = time.Now() + if status == Approved { + if approverId == "" { + return errors.New("approverId can't be empty to approve") + } + ns.AdminMetadata.ApproverID = approverId + ns.AdminMetadata.ApprovedAt = time.Now() + } + + adminMetadataByte, err := json.Marshal(ns.AdminMetadata) if err != nil { - return nil, err + return errors.Wrap(err, "Error marshalling admin metadata") } - defer rows.Close() - namespaces := make([]*Namespace, 0) - for rows.Next() { - ns := &Namespace{} - if err := rows.Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &ns.AdminMetadata); err != nil { - return nil, err + query := `UPDATE namespace SET admin_metadata = ? WHERE id = ?` + tx, err := db.Begin() + if err != nil { + return err + } + _, err = tx.Exec(query, string(adminMetadataByte), ns.ID) + if err != nil { + if errRoll := tx.Rollback(); errRoll != nil { + log.Errorln("Failed to rollback transaction:", errRoll) } - namespaces = append(namespaces, ns) + return errors.Wrap(err, "Failed to execute update query") } - - return namespaces, nil + return tx.Commit() } -func getNamespacesByServerType(serverType ServerType) ([]*Namespace, error) { - query := "" - if serverType == CacheType { - // Refer to the cache prefix name in cmd/cache_serve - query = `SELECT * FROM NAMESPACE WHERE PREFIX LIKE '/caches/%'` - } else if serverType == OriginType { - query = `SELECT * FROM NAMESPACE WHERE NOT PREFIX LIKE '/caches/%'` - } else { - return nil, errors.New(fmt.Sprint("Can't get namespace: unsupported server type: ", serverType)) +func deleteNamespace(prefix string) error { + deleteQuery := `DELETE FROM namespace WHERE prefix = ?` + tx, err := db.Begin() + if err != nil { + return err } + _, err = tx.Exec(deleteQuery, prefix) + if err != nil { + if errRoll := tx.Rollback(); errRoll != nil { + log.Errorln("Failed to rollback transaction:", errRoll) + } + return errors.Wrap(err, "Failed to execute deletion query") + } + return tx.Commit() +} +func getAllNamespaces() ([]*Namespace, error) { + query := `SELECT id, prefix, pubkey, identity, admin_metadata FROM namespace ORDER BY id ASC` rows, err := db.Query(query) if err != nil { return nil, err @@ -366,9 +591,16 @@ func getNamespacesByServerType(serverType ServerType) ([]*Namespace, error) { namespaces := make([]*Namespace, 0) for rows.Next() { ns := &Namespace{} - if err := rows.Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &ns.AdminMetadata); err != nil { + adminMetadataStr := "" + if err := rows.Scan(&ns.ID, &ns.Prefix, &ns.Pubkey, &ns.Identity, &adminMetadataStr); err != nil { return nil, err } + // For backward compatibility, if adminMetadata is an empty string, don't unmarshall json + if adminMetadataStr != "" { + if err := json.Unmarshal([]byte(adminMetadataStr), &ns.AdminMetadata); err != nil { + return nil, err + } + } namespaces = append(namespaces, ns) } diff --git a/registry/registry_db_test.go b/registry/registry_db_test.go index 6fa4be2e8..0daae94c0 100644 --- a/registry/registry_db_test.go +++ b/registry/registry_db_test.go @@ -21,6 +21,7 @@ package registry import ( "database/sql" "testing" + "time" "github.com/pkg/errors" "github.com/stretchr/testify/assert" @@ -37,26 +38,21 @@ import ( "github.com/spf13/viper" ) -func setupMockNamespaceDB() error { +func setupMockRegistryDB(t *testing.T) { mockDB, err := sql.Open("sqlite", ":memory:") db = mockDB - if err != nil { - return err - } + require.NoError(t, err, "Error setting up mock namespace DB") createNamespaceTable() - return nil } -func resetNamespaceDB() error { +func resetNamespaceDB(t *testing.T) { _, err := db.Exec(`DELETE FROM namespace`) - if err != nil { - return err - } - return nil + require.NoError(t, err, "Error resetting namespace DB") } -func teardownMockNamespaceDB() { - db.Close() +func teardownMockNamespaceDB(t *testing.T) { + err := db.Close() + require.NoError(t, err, "Error tearing down mock namespace DB") } func insertMockDBData(nss []Namespace) error { @@ -66,7 +62,15 @@ func insertMockDBData(nss []Namespace) error { return err } for _, ns := range nss { - _, err = tx.Exec(query, ns.Prefix, ns.Pubkey, ns.Identity, ns.AdminMetadata) + adminMetaStr, err := json.Marshal(ns.AdminMetadata) + if err != nil { + if errRoll := tx.Rollback(); errRoll != nil { + return errors.Wrap(errRoll, "Failed to rollback transaction") + } + return err + } + + _, err = tx.Exec(query, ns.Prefix, ns.Pubkey, ns.Identity, adminMetaStr) if err != nil { if errRoll := tx.Rollback(); errRoll != nil { return errors.Wrap(errRoll, "Failed to rollback transaction") @@ -112,7 +116,7 @@ func compareNamespaces(execpted []Namespace, returned interface{}, woPubkey bool return true } -func mockNamespace(prefix, pubkey, identity, adminMetadata string) Namespace { +func mockNamespace(prefix, pubkey, identity string, adminMetadata AdminMetadata) Namespace { return Namespace{ Prefix: prefix, Pubkey: pubkey, @@ -125,12 +129,12 @@ func mockNamespace(prefix, pubkey, identity, adminMetadata string) Namespace { // functinos in this package. Please treat them as "constants" var ( mockNssWithOrigins []Namespace = []Namespace{ - mockNamespace("/test1", "pubkey1", "", ""), - mockNamespace("/test2", "pubkey2", "", ""), + mockNamespace("/test1", "pubkey1", "", AdminMetadata{}), + mockNamespace("/test2", "pubkey2", "", AdminMetadata{}), } mockNssWithCaches []Namespace = []Namespace{ - mockNamespace("/caches/random1", "pubkey1", "", ""), - mockNamespace("/caches/random2", "pubkey2", "", ""), + mockNamespace("/caches/random1", "pubkey1", "", AdminMetadata{}), + mockNamespace("/caches/random2", "pubkey2", "", AdminMetadata{}), } mockNssWithMixed []Namespace = func() (mixed []Namespace) { mixed = append(mixed, mockNssWithOrigins...) @@ -139,16 +143,272 @@ var ( }() ) +func TestGetNamespacesById(t *testing.T) { + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) + + t.Run("return-error-with-empty-db", func(t *testing.T) { + _, err := getNamespaceById(1) + assert.Error(t, err) + }) + + t.Run("return-error-with-invalid-id", func(t *testing.T) { + _, err := getNamespaceById(0) + assert.Error(t, err) + + _, err = getNamespaceById(-1) + assert.Error(t, err) + }) + + t.Run("return-namespace-with-correct-id", func(t *testing.T) { + defer resetNamespaceDB(t) + mockNs := mockNamespace("/test", "", "", AdminMetadata{UserID: "foo"}) + err := insertMockDBData([]Namespace{mockNs}) + require.NoError(t, err) + nss, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(nss)) + + got, err := getNamespaceById(nss[0].ID) + require.NoError(t, err, "Error getting namespace by ID") + mockNs.ID = nss[0].ID + assert.Equal(t, mockNs, *got) + }) + + t.Run("return-error-with-id-dne", func(t *testing.T) { + err := insertMockDBData(mockNssWithOrigins) + require.NoError(t, err) + defer resetNamespaceDB(t) + _, err = getNamespaceById(100) + assert.Error(t, err) + }) +} + +func TestGetNamespacesByUserID(t *testing.T) { + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) + + t.Run("empty-db-return-empty-array", func(t *testing.T) { + nss, err := getNamespacesByUserID("foo") + require.NoError(t, err) + assert.Equal(t, 0, len(nss)) + }) + + t.Run("return-empty-array-with-no-userid-entries", func(t *testing.T) { + err := insertMockDBData(mockNssWithMixed) + require.NoError(t, err) + defer resetNamespaceDB(t) + nss, err := getNamespacesByUserID("foo") + require.NoError(t, err) + assert.Equal(t, 0, len(nss)) + }) + + t.Run("return-user-namespace-with-valid-userID", func(t *testing.T) { + defer resetNamespaceDB(t) + err := insertMockDBData(mockNssWithMixed) + require.NoError(t, err) + err = insertMockDBData([]Namespace{mockNamespace("/user1", "", "user1", AdminMetadata{UserID: "user1"})}) + require.NoError(t, err) + nss, err := getNamespacesByUserID("user1") + require.NoError(t, err) + require.Equal(t, 1, len(nss)) + assert.Equal(t, "/user1", nss[0].Prefix) + }) + + t.Run("return-multiple-user-namespaces-with-valid-userID", func(t *testing.T) { + defer resetNamespaceDB(t) + err := insertMockDBData(mockNssWithMixed) + require.NoError(t, err) + err = insertMockDBData([]Namespace{mockNamespace("/user1", "", "user1", AdminMetadata{UserID: "user1"})}) + require.NoError(t, err) + err = insertMockDBData([]Namespace{mockNamespace("/user1-2", "", "user1", AdminMetadata{UserID: "user1"})}) + require.NoError(t, err) + nss, err := getNamespacesByUserID("user1") + require.NoError(t, err) + require.Equal(t, 2, len(nss)) + assert.Equal(t, "/user1", nss[0].Prefix) + assert.Equal(t, "/user1-2", nss[1].Prefix) + + }) +} + +func TestAddNamespace(t *testing.T) { + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) + + t.Run("set-default-fields", func(t *testing.T) { + defer resetNamespaceDB(t) + mockNs := mockNamespace("/test", "pubkey", "identity", AdminMetadata{UserID: "someone"}) + err := addNamespace(&mockNs) + require.NoError(t, err) + got, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + // We can do this becuase we pass the pointer of mockNs to addNamespce which + // then modify the fields and insert into database + assert.Equal(t, mockNs.AdminMetadata.CreatedAt.UTC(), got[0].AdminMetadata.CreatedAt) + assert.Equal(t, mockNs.AdminMetadata.UpdatedAt.UTC(), got[0].AdminMetadata.UpdatedAt) + assert.Equal(t, mockNs.AdminMetadata.Status, got[0].AdminMetadata.Status) + }) + + t.Run("override-restricted-fields", func(t *testing.T) { + defer resetNamespaceDB(t) + mockCreateAt := time.Now().Add(time.Hour * 10) + mockUpdatedAt := time.Now().Add(time.Minute * 20) + mockNs := mockNamespace("/test", "pubkey", "identity", AdminMetadata{UserID: "someone", CreatedAt: mockCreateAt, UpdatedAt: mockUpdatedAt}) + err := addNamespace(&mockNs) + require.NoError(t, err) + got, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + + assert.NotEqual(t, mockCreateAt.UTC(), mockNs.AdminMetadata.CreatedAt.UTC()) + assert.NotEqual(t, mockUpdatedAt.UTC(), mockNs.AdminMetadata.UpdatedAt.UTC()) + // We can do this becuase we pass the pointer of mockNs to addNamespce which + // then modify the fields and insert into database + assert.Equal(t, mockNs.AdminMetadata.CreatedAt.UTC(), got[0].AdminMetadata.CreatedAt) + assert.Equal(t, mockNs.AdminMetadata.UpdatedAt.UTC(), got[0].AdminMetadata.UpdatedAt) + assert.Equal(t, mockNs.AdminMetadata.Status, got[0].AdminMetadata.Status) + }) + + t.Run("insert-data-integrity", func(t *testing.T) { + defer resetNamespaceDB(t) + mockNs := mockNamespace("/test", "pubkey", "identity", AdminMetadata{UserID: "someone", Description: "Some description", SiteName: "OSG", SecurityContactUserID: "security-001"}) + err := addNamespace(&mockNs) + require.NoError(t, err) + got, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + assert.Equal(t, mockNs.Pubkey, got[0].Pubkey) + assert.Equal(t, mockNs.Identity, got[0].Identity) + assert.Equal(t, mockNs.AdminMetadata.Description, got[0].AdminMetadata.Description) + assert.Equal(t, mockNs.AdminMetadata.SiteName, got[0].AdminMetadata.SiteName) + assert.Equal(t, mockNs.AdminMetadata.SecurityContactUserID, got[0].AdminMetadata.SecurityContactUserID) + }) +} + +func TestUpdateNamespace(t *testing.T) { + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) + + t.Run("update-on-dne-entry-returns-error", func(t *testing.T) { + defer resetNamespaceDB(t) + mockNs := mockNamespace("/test", "", "", AdminMetadata{}) + err := updateNamespace(&mockNs) + assert.Error(t, err) + }) + + t.Run("update-preserve-internal-fields", func(t *testing.T) { + defer resetNamespaceDB(t) + mockNs := mockNamespace("/test", "", "", AdminMetadata{UserID: "foo"}) + err := insertMockDBData([]Namespace{mockNs}) + require.NoError(t, err) + initialNss, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(initialNss)) + initialNs := initialNss[0] + assert.Equal(t, mockNs.Prefix, initialNs.Prefix) + initialNs.AdminMetadata.UserID = "bar" + initialNs.AdminMetadata.CreatedAt = time.Now().Add(10 * time.Hour) + initialNs.AdminMetadata.UpdatedAt = time.Now().Add(10 * time.Hour) + initialNs.AdminMetadata.Status = Approved + initialNs.AdminMetadata.ApproverID = "hacker" + initialNs.AdminMetadata.ApprovedAt = time.Now().Add(10 * time.Hour) + err = updateNamespace(initialNs) + require.NoError(t, err) + finalNss, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(finalNss)) + finalNs := finalNss[0] + assert.Equal(t, mockNs.Prefix, finalNs.Prefix) + assert.Equal(t, mockNs.AdminMetadata.UserID, finalNs.AdminMetadata.UserID) + assert.Equal(t, mockNs.AdminMetadata.CreatedAt.UTC(), finalNs.AdminMetadata.CreatedAt) + assert.Equal(t, mockNs.AdminMetadata.Status, finalNs.AdminMetadata.Status) + assert.Equal(t, mockNs.AdminMetadata.ApprovedAt.UTC(), finalNs.AdminMetadata.ApprovedAt) + assert.Equal(t, mockNs.AdminMetadata.ApproverID, finalNs.AdminMetadata.ApproverID) + // DB first changes initialNs.AdminMetadata.UpdatedAt then commit + assert.Equal(t, initialNs.AdminMetadata.UpdatedAt.UTC(), finalNs.AdminMetadata.UpdatedAt) + }) +} + +func TestUpdateNamespaceStatusById(t *testing.T) { + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) + t.Run("return-error-if-id-dne", func(t *testing.T) { + defer resetNamespaceDB(t) + err := insertMockDBData(mockNssWithOrigins) + require.NoError(t, err) + err = updateNamespaceStatusById(100, Approved, "random") + assert.Error(t, err) + }) + + t.Run("return-error-if-invalid-approver-userId", func(t *testing.T) { + defer resetNamespaceDB(t) + + mockNs := mockNamespace("/test", "pubkey", "identity", AdminMetadata{UserID: "someone"}) + err := insertMockDBData([]Namespace{mockNs}) + require.NoError(t, err) + got, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + err = updateNamespaceStatusById(got[0].ID, Approved, "") + assert.Error(t, err) + }) + + t.Run("update-status-with-valid-input-for-approval", func(t *testing.T) { + defer resetNamespaceDB(t) + + mockNs := mockNamespace("/test", "pubkey", "identity", AdminMetadata{UserID: "someone"}) + err := insertMockDBData([]Namespace{mockNs}) + require.NoError(t, err) + got, err := getAllNamespaces() + require.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + err = updateNamespaceStatusById(got[0].ID, Approved, "approver1") + assert.NoError(t, err) + got, err = getAllNamespaces() + assert.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + assert.Equal(t, Approved, got[0].AdminMetadata.Status) + assert.Equal(t, "approver1", got[0].AdminMetadata.ApproverID) + assert.NotEqual(t, time.Time{}, got[0].AdminMetadata.ApprovedAt) + }) + + t.Run("deny-does-not-modify-approval-fields", func(t *testing.T) { + defer resetNamespaceDB(t) + + mockNs := mockNamespace("/test", "pubkey", "identity", AdminMetadata{UserID: "someone"}) + err := insertMockDBData([]Namespace{mockNs}) + assert.NoError(t, err) + got, err := getAllNamespaces() + assert.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + err = updateNamespaceStatusById(got[0].ID, Denied, "approver1") + assert.NoError(t, err) + got, err = getAllNamespaces() + assert.NoError(t, err) + require.Equal(t, 1, len(got)) + assert.Equal(t, mockNs.Prefix, got[0].Prefix) + assert.Equal(t, Denied, got[0].AdminMetadata.Status) + assert.Equal(t, "", got[0].AdminMetadata.ApproverID) + assert.Equal(t, time.Time{}, got[0].AdminMetadata.ApprovedAt) + }) +} + // teardown must be called at the end of the test to close the in-memory SQLite db func TestGetNamespacesByServerType(t *testing.T) { - - err := setupMockNamespaceDB() - require.NoError(t, err, "Error setting up the mock namespace DB") - defer teardownMockNamespaceDB() + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) t.Run("wrong-server-type-gives-error", func(t *testing.T) { - err := resetNamespaceDB() - require.NoError(t, err) + resetNamespaceDB(t) rss, err := getNamespacesByServerType("") require.Error(t, err, "No error returns when give empty server type") @@ -160,8 +420,7 @@ func TestGetNamespacesByServerType(t *testing.T) { }) t.Run("empty-db-returns-empty-list", func(t *testing.T) { - err := resetNamespaceDB() - require.NoError(t, err) + resetNamespaceDB(t) origins, err := getNamespacesByServerType(OriginType) require.NoError(t, err) @@ -173,10 +432,9 @@ func TestGetNamespacesByServerType(t *testing.T) { }) t.Run("returns-origins-as-expected", func(t *testing.T) { - err := resetNamespaceDB() - require.NoError(t, err) + resetNamespaceDB(t) - err = insertMockDBData(mockNssWithOrigins) + err := insertMockDBData(mockNssWithOrigins) require.NoError(t, err) origins, err := getNamespacesByServerType(OriginType) @@ -190,10 +448,9 @@ func TestGetNamespacesByServerType(t *testing.T) { }) t.Run("return-caches-as-expected", func(t *testing.T) { - err := resetNamespaceDB() - require.NoError(t, err) + resetNamespaceDB(t) - err = insertMockDBData(mockNssWithCaches) + err := insertMockDBData(mockNssWithCaches) require.NoError(t, err) caches, err := getNamespacesByServerType(CacheType) @@ -207,10 +464,9 @@ func TestGetNamespacesByServerType(t *testing.T) { }) t.Run("return-correctly-with-mixed-server-type", func(t *testing.T) { - err := resetNamespaceDB() - require.NoError(t, err) + resetNamespaceDB(t) - err = insertMockDBData(mockNssWithMixed) + err := insertMockDBData(mockNssWithMixed) require.NoError(t, err) caches, err := getNamespacesByServerType(CacheType) @@ -284,7 +540,7 @@ func TestRegistryTopology(t *testing.T) { Prefix: "/regular/foo", Pubkey: "", Identity: "", - AdminMetadata: "", + AdminMetadata: AdminMetadata{}, } err = addNamespace(&ns) require.NoError(t, err) @@ -343,13 +599,7 @@ func TestCacheAdminTrue(t *testing.T) { err := InitializeDB() defer ShutdownDB() - require.NoError(t, err, "error initializing registry database") - jResult, err := json.Marshal(AdminJSON{ - AdminApproved: true, - }) - - require.NoError(t, err, "error marshalling json admin data") adminTester := func(ns Namespace) func(t *testing.T) { return func(t *testing.T) { @@ -357,9 +607,9 @@ func TestCacheAdminTrue(t *testing.T) { require.NoError(t, err, "error adding test cache to registry database") - // This will return a serverCredsError if the admin_approval == false check is triggered, which we don't want to happen + // This will return a serverCredsError if the AdminMetadata.Status != Approved, which we don't want to happen // For these tests, otherwise it will get a key parsing error as ns.Pubkey isn't a real jwk - _, err = dbGetPrefixJwks(ns.Prefix, true) + _, err = getNamespaceJwksByPrefix(ns.Prefix, true) require.NotErrorIsf(t, err, serverCredsErr, "error chain contains serverCredErr") require.ErrorContainsf(t, err, "Failed to parse pubkey as a jwks: failed to unmarshal JWK set: invalid character 'k' in literal true (expecting 'r')", "error doesn't contain jwks parsing error") @@ -370,25 +620,21 @@ func TestCacheAdminTrue(t *testing.T) { ns.Prefix = "/caches/test3" ns.Identity = "testident3" ns.Pubkey = "tkey" - ns.AdminMetadata = string(jResult) + ns.AdminMetadata.Status = Approved t.Run("WithApproval", adminTester(ns)) - jResult, err = json.Marshal(AdminJSON{ - AdminApproved: false, - }) - ns.Prefix = "/orig/test1" ns.Identity = "testident4" ns.Pubkey = "tkey" - ns.AdminMetadata = string(jResult) + ns.AdminMetadata.Status = Pending t.Run("OriginNoApproval", adminTester(ns)) ns.Prefix = "/orig/test2" ns.Identity = "testident5" ns.Pubkey = "tkey" - ns.AdminMetadata = "" + ns.AdminMetadata = AdminMetadata{} t.Run("OriginEmptyApproval", adminTester(ns)) @@ -403,9 +649,6 @@ func TestCacheAdminFalse(t *testing.T) { defer ShutdownDB() require.NoError(t, err, "error initializing registry database") - jResult, err := json.Marshal(AdminJSON{ - AdminApproved: false, - }) adminTester := func(ns Namespace) func(t *testing.T) { return func(t *testing.T) { @@ -413,7 +656,7 @@ func TestCacheAdminFalse(t *testing.T) { require.NoError(t, err, "error adding test cache to registry database") // This will return a serverCredsError if the admin_approval == false check is triggered, which we want to happen - _, err = dbGetPrefixJwks(ns.Prefix, true) + _, err = getNamespaceJwksByPrefix(ns.Prefix, true) require.ErrorIs(t, err, serverCredsErr) } @@ -423,13 +666,13 @@ func TestCacheAdminFalse(t *testing.T) { ns.Prefix = "/caches/test1" ns.Identity = "testident1" ns.Pubkey = "tkey" - ns.AdminMetadata = string(jResult) + ns.AdminMetadata.Status = Pending t.Run("NoAdmin", adminTester(ns)) ns.Prefix = "/caches/test2" ns.Identity = "testident2" - ns.AdminMetadata = "" + ns.AdminMetadata = AdminMetadata{} t.Run("EmptyAdmin", adminTester(ns)) diff --git a/registry/registry_ui.go b/registry/registry_ui.go index c3c0a9dfe..4757fc9eb 100644 --- a/registry/registry_ui.go +++ b/registry/registry_ui.go @@ -22,16 +22,132 @@ import ( "encoding/json" "fmt" "net/http" + "reflect" "strconv" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/web_ui" + log "github.com/sirupsen/logrus" ) -type listNamespaceRequest struct { - ServerType string `form:"server_type"` +type ( + listNamespaceRequest struct { + ServerType string `form:"server_type"` + } + + registrationFieldType string + registrationField struct { + Name string `json:"name"` + Type registrationFieldType `json:"type"` + Required bool `json:"required"` + Options []interface{} `json:"options"` + } + + Institution struct { + Name string `mapstructure:"name" json:"name"` + ID string `mapstructure:"id" json:"id"` + } +) + +const ( + String registrationFieldType = "string" + Int registrationFieldType = "int" + Enum registrationFieldType = "enum" + DateTime registrationFieldType = "datetime" +) + +var ( + registrationFields []registrationField +) + +func init() { + registrationFields = make([]registrationField, 0) + registrationFields = append(registrationFields, populateRegistrationFields("", Namespace{})...) +} + +// Populate registrationFields array to provide available namespace registration fields +// for UI to render registration form +func populateRegistrationFields(prefix string, data interface{}) []registrationField { + var fields []registrationField + + val := reflect.ValueOf(data) + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + + // Check for the "post" tag, it can be "exlude" or "required" + if tag := field.Tag.Get("post"); tag == "exclude" { + continue + } + + name := "" + if prefix != "" { + name += prefix + "." + } + // If the field has a json tag. Use the name from json tag + tempName := field.Name + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + splitJson := strings.Split(jsonTag, ",")[0] + if splitJson != "-" { + tempName = splitJson + } else { + // `json:"-"` means this field should be removed from any marshalling + continue + } + } + + regField := registrationField{ + Name: name + tempName, + Required: strings.Contains(field.Tag.Get("validate"), "required"), + } + + switch field.Type.Kind() { + case reflect.Int: + regField.Type = Int + fields = append(fields, regField) + case reflect.String: + regField.Type = String + fields = append(fields, regField) + case reflect.Struct: + // Check if the struct is of type time.Time + if field.Type == reflect.TypeOf(time.Time{}) { + regField.Type = DateTime + fields = append(fields, regField) + break + } + // If it's AdminMetadata, add prefix and recursively call to parse fields + if field.Type == reflect.TypeOf(AdminMetadata{}) { + existing_prefix := "" + if prefix != "" { + existing_prefix = prefix + "." + } + fields = append(fields, populateRegistrationFields(existing_prefix+"admin_metadata", AdminMetadata{})...) + break + } + } + + if field.Type == reflect.TypeOf(RegistrationStatus("")) { + regField.Type = Enum + options := make([]interface{}, 3) + options[0] = Pending + options[1] = Approved + options[2] = Denied + regField.Options = options + fields = append(fields, regField) + } else { + // Skip the field if it's not in the types listed above + continue + } + } + return fields } -// Exclude pubkey field from marshalling into json +// Helper function to exclude pubkey field from marshalling into json func excludePubKey(nss []*Namespace) (nssNew []NamespaceWOPubkey) { nssNew = make([]NamespaceWOPubkey, 0) for _, ns := range nss { @@ -62,6 +178,7 @@ func listNamespaces(ctx *gin.Context) { } namespaces, err := getNamespacesByServerType(ServerType(queryParams.ServerType)) if err != nil { + log.Error("Failed to get namespaces by server type: ", err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server encountered an error trying to list namespaces"}) return } @@ -71,6 +188,7 @@ func listNamespaces(ctx *gin.Context) { } else { namespaces, err := getAllNamespaces() if err != nil { + log.Error("Failed to get all namespaces: ", err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server encountered an error trying to list namespaces"}) return } @@ -79,6 +197,225 @@ func listNamespaces(ctx *gin.Context) { } } +func listNamespacesForUser(ctx *gin.Context) { + user := ctx.GetString("User") + if user == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "You need to login to perform this action"}) + return + } + namespaces, err := getNamespacesByUserID(user) + if err != nil { + log.Error("Error getting namespaces for user ", user) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting namespaces by user ID"}) + return + } + ctx.JSON(http.StatusOK, namespaces) +} + +func getNamespaceRegFields(ctx *gin.Context) { + ctx.JSON(http.StatusOK, registrationFields) +} + +func createUpdateNamespace(ctx *gin.Context, isUpdate bool) { + user := ctx.GetString("User") + id := 0 // namespace ID when doing update + if user == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "You need to login to perform this action"}) + return + } + if isUpdate { + idStr := ctx.Param("id") + var err error + id, err = strconv.Atoi(idStr) + if err != nil || id <= 0 { + // Handle the error if id is not a valid integer + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format. ID must a non-zero integer"}) + return + } + } + + ns := Namespace{} + if ctx.ShouldBindJSON(&ns) != nil { + ctx.JSON(400, gin.H{"error": "Invalid create or update namespace request"}) + return + } + // Basic validation (type, required, etc) + errs := config.GetValidate().Struct(ns) + if errs != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprint(errs)}) + return + } + // Check that Prefix is a valid prefix + updated_prefix, err := validatePrefix(ns.Prefix) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprint("Error: Field validation for prefix failed:", err)}) + return + } + ns.Prefix = updated_prefix + + // Check if prefix exists before doing anything else + exists, err := namespaceExists(ns.Prefix) + if err != nil { + log.Errorf("Failed to check if namespace already exists: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server encountered an error checking if namespace already exists"}) + return + } + if exists { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("The prefix %s is already registered", ns.Prefix)}) + return + } + // Check if pubKey is a valid JWK + pubkey, err := validateJwks(ns.Pubkey) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprint("Error: Field validation for pubkey failed:", err)}) + return + } + + // Check if the parent or child path along the prefix has been registered + valErr, sysErr := validateKeyChaining(ns.Prefix, pubkey) + if valErr != nil { + log.Errorln(valErr) + ctx.JSON(http.StatusBadRequest, gin.H{"error": valErr}) + return + } + if sysErr != nil { + log.Errorln(sysErr) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": sysErr}) + return + } + + if validInst, err := validateInstitution(ns.AdminMetadata.Institution); !validInst { + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Error validating institution: %v", err)}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Institution \"%s\" is not in the list of available institutions to register.", ns.AdminMetadata.Institution)}) + return + } + + if !isUpdate { // Create + ns.AdminMetadata.UserID = user + // Overwrite status to Pending to filter malicious request + ns.AdminMetadata.Status = Pending + if err := addNamespace(&ns); err != nil { + log.Errorf("Failed to insert namespace with id %d. %v", ns.ID, err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fail to insert namespace"}) + return + } + ctx.JSON(http.StatusOK, gin.H{"msg": "success"}) + } else { // Update + // First check if the namespace exists + exists, err := namespaceExistsById(ns.ID) + if err != nil { + log.Error("Failed to get namespace by ID:", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fail to find if namespace exists"}) + return + } + + if !exists { // Return 404 is the namespace does not exists + ctx.JSON(http.StatusNotFound, gin.H{"error": "Can't update namespace: namespace not found"}) + return + } + + // Then check if the user has previlege to update + isAdmin, _ := checkAdmin(user) + if !isAdmin { // Not admin, need to check if the namespace belongs to the user + found, err := namespaceBelongsToUserId(id, user) + if err != nil { + log.Error("Error checking if namespace belongs to the user: ", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error checking if namespace belongs to the user"}) + return + } + if !found { + log.Errorf("Namespace not found for id: %d", id) + ctx.JSON(http.StatusNotFound, gin.H{"error": "Namespace not found. Check the id or if you own the namespace"}) + return + } + } + // If the user has previlege to udpate, go ahead + if err := updateNamespace(&ns); err != nil { + log.Errorf("Failed to update namespace with id %d. %v", ns.ID, err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fail to update namespace"}) + return + } + } +} + +func getNamespace(ctx *gin.Context) { + // Admin can see any namespace detail while non-admin can only see his/her namespace + user := ctx.GetString("User") + idStr := ctx.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + // Handle the error if id is not a valid integer + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format. ID must a non-zero integer"}) + return + } + exists, err := namespaceExistsById(id) + if err != nil { + log.Error("Error checking if namespace exists: ", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error checking if namespace exists"}) + return + } + if !exists { + log.Errorf("Namespace not found for id: %d", id) + ctx.JSON(http.StatusNotFound, gin.H{"error": "Namespace not found"}) + return + } + + isAdmin, _ := checkAdmin(user) + if !isAdmin { // Not admin, need to check if the namespace belongs to the user + found, err := namespaceBelongsToUserId(id, user) + if err != nil { + log.Error("Error checking if namespace belongs to the user: ", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error checking if namespace belongs to the user"}) + return + } + if !found { // If the user doen's own the namespace, they can't update it + log.Errorf("Namespace not found for id: %d", id) + ctx.JSON(http.StatusForbidden, gin.H{"error": "Namespace not found. Check the id or if you own the namespace"}) + return + } + } + + ns, err := getNamespaceById(id) + if err != nil { + log.Error("Error getting namespace: ", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting namespace"}) + return + } + ctx.JSON(http.StatusOK, ns) +} + +func updateNamespaceStatus(ctx *gin.Context, status RegistrationStatus) { + user := ctx.GetString("User") + idStr := ctx.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + // Handle the error if id is not a valid integer + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format. ID must a non-zero integer"}) + return + } + exists, err := namespaceExistsById(id) + if err != nil { + log.Error("Error checking if namespace exists: ", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error checking if namespace exists"}) + return + } + if !exists { + log.Errorf("Namespace not found for id: %d", id) + ctx.JSON(http.StatusNotFound, gin.H{"error": "Namespace not found"}) + return + } + + if err = updateNamespaceStatusById(id, status, user); err != nil { + log.Error("Error updating namespace status by ID:", id, " to status:", status) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update namespace"}) + return + } + ctx.JSON(http.StatusOK, gin.H{"msg": "ok"}) +} + func getNamespaceJWKS(ctx *gin.Context) { idStr := ctx.Param("id") id, err := strconv.Atoi(idStr) @@ -96,7 +433,7 @@ func getNamespaceJWKS(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{"error": "Namespace not found"}) return } - jwks, err := getPrefixJwksById(id) + jwks, err := getNamespaceJwksById(id) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprint("Error getting jwks by id:", err)}) return @@ -112,11 +449,89 @@ func getNamespaceJWKS(ctx *gin.Context) { ctx.Data(200, "application/json", jsonData) } -func RegisterRegistryWebAPI(router *gin.RouterGroup) { +func listInstitutions(ctx *gin.Context) { + institutions := []Institution{} + if err := param.Registry_Institutions.Unmarshal(&institutions); err != nil { + log.Error("Fail to read server configuration of institutions", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fail to read server configuration of institutions"}) + return + } + + if len(institutions) == 0 { + log.Error("Server didn't configure Registry.Institutions") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server didn't configure Registry.Institutions"}) + return + } + ctx.JSON(http.StatusOK, institutions) +} + +// checkAdmin checks if a user string has admin privilege. It returns boolean and a message +// indicating the error message +func checkAdmin(user string) (isAdmin bool, message string) { + if user == "admin" { + return true, "" + } + adminList := param.Registry_AdminUsers.GetStringSlice() + for _, admin := range adminList { + if user == admin { + return true, "" + } + } + return false, "You don't have permission to perform this action" +} + +// adminAuthHandler checks the admin status of a logged-in user. This middleware +// should be cascaded behind the [web_ui.AuthHandler] +func adminAuthHandler(ctx *gin.Context) { + user := ctx.GetString("User") + // This should be done by a regular auth handler from the upstream, but we check here just in case + if user == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Login required to view this page"}) + } + isAdmin, msg := checkAdmin(user) + if isAdmin { + ctx.Next() + return + } else { + ctx.JSON(http.StatusForbidden, gin.H{"error": msg}) + } +} + +// Define Gin APIs for registry Web UI. All endpoints are user-facing +func RegisterRegistryWebAPI(router *gin.RouterGroup) error { registryWebAPI := router.Group("/api/v1.0/registry_ui") + csrfHandler, err := config.GetCSRFHandler() + if err != nil { + return err + } + // Add CSRF middleware to all the routes below. CSRF middleware will look for + // any update methods (post/delete/patch, etc) and automatically check if a + // X-CSRF-Token header is present and the token matches + registryWebAPI.Use(csrfHandler) // Follow RESTful schema { registryWebAPI.GET("/namespaces", listNamespaces) + registryWebAPI.OPTIONS("/namespaces", web_ui.AuthHandler, getNamespaceRegFields) + registryWebAPI.POST("/namespaces", web_ui.AuthHandler, func(ctx *gin.Context) { + createUpdateNamespace(ctx, false) + }) + + registryWebAPI.GET("/namespaces/user", web_ui.AuthHandler, listNamespacesForUser) + + registryWebAPI.GET("/namespaces/:id", web_ui.AuthHandler, getNamespace) + registryWebAPI.PUT("/namespaces/:id", web_ui.AuthHandler, func(ctx *gin.Context) { + createUpdateNamespace(ctx, true) + }) registryWebAPI.GET("/namespaces/:id/pubkey", getNamespaceJWKS) + registryWebAPI.PATCH("/namespaces/:id/approve", web_ui.AuthHandler, adminAuthHandler, func(ctx *gin.Context) { + updateNamespaceStatus(ctx, Approved) + }) + registryWebAPI.PATCH("/namespaces/:id/deny", web_ui.AuthHandler, adminAuthHandler, func(ctx *gin.Context) { + updateNamespaceStatus(ctx, Denied) + }) + } + { + registryWebAPI.GET("/institutions", web_ui.AuthHandler, listInstitutions) } + return nil } diff --git a/registry/registry_ui_test.go b/registry/registry_ui_test.go index 18be07ad0..00cec6dbb 100644 --- a/registry/registry_ui_test.go +++ b/registry/registry_ui_test.go @@ -14,6 +14,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/pkg/errors" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -66,11 +67,8 @@ func GenerateMockJWKS() (string, error) { func TestListNamespaces(t *testing.T) { // Initialize the mock database - err := setupMockNamespaceDB() - if err != nil { - t.Fatalf("Failed to set up mock namespace DB: %v", err) - } - defer teardownMockNamespaceDB() + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) router := gin.Default() @@ -125,10 +123,7 @@ func TestListNamespaces(t *testing.T) { } } defer func() { - err := resetNamespaceDB() - if err != nil { - t.Fatalf("Failed to reset mock namespace DB: %v", err) - } + resetNamespaceDB(t) }() // Create a request to the endpoint @@ -163,11 +158,8 @@ func TestGetNamespaceJWKS(t *testing.T) { t.Fatalf("Failed to set up mock public key: %v", err) } // Initialize the mock database - err = setupMockNamespaceDB() - if err != nil { - t.Fatalf("Failed to set up mock namespace DB: %v", err) - } - defer teardownMockNamespaceDB() + setupMockRegistryDB(t) + defer teardownMockNamespaceDB(t) router := gin.Default() @@ -229,12 +221,7 @@ func TestGetNamespaceJWKS(t *testing.T) { } } - defer func() { - err := resetNamespaceDB() - if err != nil { - t.Fatalf("Failed to reset mock namespace DB: %v", err) - } - }() + defer resetNamespaceDB(t) // Create a request to the endpoint w := httptest.NewRecorder() @@ -251,3 +238,89 @@ func TestGetNamespaceJWKS(t *testing.T) { }) } } + +func TestAdminAuthHandler(t *testing.T) { + // Initialize Gin and set it to test mode + gin.SetMode(gin.TestMode) + + // Define test cases + testCases := []struct { + name string + setupUserFunc func(*gin.Context) // Function to setup user and admin list + expectedCode int // Expected HTTP status code + expectedError string // Expected error message + }{ + { + name: "user-not-logged-in", + setupUserFunc: func(ctx *gin.Context) { + viper.Set("Registry.AdminUsers", []string{"admin1", "admin2"}) + ctx.Set("User", "") + }, + expectedCode: http.StatusUnauthorized, + expectedError: "Login required to view this page", + }, + { + name: "general-admin-access", + setupUserFunc: func(ctx *gin.Context) { + viper.Set("Registry.AdminUsers", []string{}) + ctx.Set("User", "admin") + }, + expectedCode: http.StatusOK, + }, + { + name: "specific-admin-user-access", + setupUserFunc: func(ctx *gin.Context) { + viper.Set("Registry.AdminUsers", []string{"admin1", "admin2"}) + ctx.Set("User", "admin1") + }, + expectedCode: http.StatusOK, + }, + { + name: "non-admin-user-access", + setupUserFunc: func(ctx *gin.Context) { + viper.Set("Registry.AdminUsers", []string{"admin1", "admin2"}) + ctx.Set("User", "user") + }, + expectedCode: http.StatusForbidden, + expectedError: "You don't have permission to perform this action", + }, + { + name: "admin-list-empty", + setupUserFunc: func(ctx *gin.Context) { + viper.Set("Registry.AdminUsers", []string{}) + ctx.Set("User", "user") + }, + expectedCode: http.StatusForbidden, + expectedError: "You don't have permission to perform this action", + }, + { + name: "admin-list-multiple-users", + setupUserFunc: func(ctx *gin.Context) { + viper.Set("Registry.AdminUsers", []string{"admin1", "admin2", "admin3"}) + ctx.Set("User", "admin2") + }, + expectedCode: http.StatusOK, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + tc.setupUserFunc(ctx) + + adminAuthHandler(ctx) + + assert.Equal(t, tc.expectedCode, w.Code) + if tc.expectedError != "" { + assert.Contains(t, w.Body.String(), tc.expectedError) + } + viper.Reset() + }) + } +} + +func TestPopulateRegistrationFields(t *testing.T) { + result := populateRegistrationFields("", Namespace{}) + assert.NotEqual(t, 0, len(result)) +} diff --git a/registry/registry_validation.go b/registry/registry_validation.go new file mode 100644 index 000000000..bf1fb46b3 --- /dev/null +++ b/registry/registry_validation.go @@ -0,0 +1,171 @@ +/*************************************************************** + * + * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research + * + * 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 registry + +import ( + "encoding/json" + "strings" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/pelicanplatform/pelican/param" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// This file has all custom validator logic for registry struct +// data validation besides the ones already included in validator package + +func validatePrefix(nspath string) (string, error) { + if len(nspath) == 0 { + return "", errors.New("Path prefix may not be empty") + } + if nspath[0] != '/' { + return "", errors.New("Path prefix must be absolute - relative paths are not allowed") + } + components := strings.Split(nspath, "/")[1:] + if len(components) == 0 { + return "", errors.New("Cannot register the prefix '/' for an origin") + } else if components[0] == "api" { + return "", errors.New("Cannot register a prefix starting with '/api'") + } else if components[0] == "view" { + return "", errors.New("Cannot register a prefix starting with '/view'") + } else if components[0] == "pelican" { + return "", errors.New("Cannot register a prefix starting with '/pelican'") + } + result := "" + for _, component := range components { + if len(component) == 0 { + continue + } else if component == "." { + return "", errors.New("Path component cannot be '.'") + } else if component == ".." { + return "", errors.New("Path component cannot be '..'") + } else if component[0] == '.' { + return "", errors.New("Path component cannot begin with a '.'") + } + result += "/" + component + } + if result == "/" || len(result) == 0 { + return "", errors.New("Cannot register the prefix '/' for an origin") + } + + return result, nil +} + +func validateKeyChaining(prefix string, pubkey jwk.Key) (validationError error, serverError error) { + if param.Registry_RequireKeyChaining.GetBool() { + superspaces, subspaces, inTopo, err := namespaceSupSubChecks(prefix) + if err != nil { + serverError = errors.Wrap(err, "Server encountered an error checking if namespace already exists") + return + } + + // if not in OSDF mode, this will be false + if inTopo { + validationError = errors.New("Cannot register a super or subspace of a namespace already registered in topology") + return + } + // If we make the assumption that namespace prefixes are hierarchical, eg that the owner of /foo should own + // everything under /foo (/foo/bar, /foo/baz, etc), then it makes sense to check for superspaces first. If any + // superspace is found, they logically "own" the incoming namespace. + if len(superspaces) > 0 { + // If this is the case, we want to make sure that at least one of the superspaces has the + // same registration key as the incoming. This guarantees the owner of the superspace is + // permitting the action (assuming their keys haven't been stolen!) + matched, err := matchKeys(pubkey, superspaces) + if err != nil { + serverError = errors.Errorf("%v: Unable to check if the incoming key for %s matched any public keys for %s", err, prefix, subspaces) + return + } + if !matched { + validationError = errors.New("Cannot register a namespace that is suffixed or prefixed by an already-registered namespace unless the incoming public key matches a registered key") + return + } + + } else if len(subspaces) > 0 { + // If there are no superspaces, we can check the subspaces. + + // TODO: Eventually we might want to check only the highest level subspaces and use those keys for matching. For example, + // if /foo/bar and /foo/bar/baz are registered with two keysets such that the complement of their intersections is not null, + // it may be the case that the only key we match against belongs to /foo/bar/baz. If we go ahead with registration at that + // point, we're essentially saying /foo/bar/baz, the logical subspace of /foo/bar, has authorized a superspace for both. + // More interestingly, if /foo/bar and /foo/baz are both registered, should they both be consulted before adding /foo? + + // For now, we'll just check for any key match. + matched, err := matchKeys(pubkey, subspaces) + if err != nil { + serverError = errors.Errorf("%v: Unable to check if the incoming key for %s matched any public keys for %s", err, prefix, subspaces) + return + } + if !matched { + validationError = errors.New("Cannot register a namespace that is suffixed or prefixed by an already-registered namespace unless the incoming public key matches a registered key") + return + } + } + } + return +} + +func validateJwks(jwksStr string) (jwk.Key, error) { + clientJwks, err := jwk.ParseString(jwksStr) + if err != nil { + return nil, errors.Wrap(err, "Couldn't parse the pubkey from the request") + } + + if log.IsLevelEnabled(log.DebugLevel) { + // Let's check that we can convert to JSON and get the right thing... + jsonbuf, err := json.Marshal(clientJwks) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal the reuqest pubKey's keyset into JSON") + } + log.Debugln("Client JWKS as seen by the registry server:", string(jsonbuf)) + } + + /* + * TODO: This section makes the assumption that the incoming jwks only contains a single + * key, a property that is enforced by the client at the origin. Eventually we need + * to support the addition of other keys in the jwks stored for the origin. There is + * a similar TODO listed in client_commands.go, as the choices made there mirror the + * choices made here. + */ + key, exists := clientJwks.Key(0) + if !exists { + return nil, errors.New("There was no key at index 0 in the reuqest pubKey's JWKS. Something is wrong") + } + return key, nil +} + +// Validates if the instID, the id of the institution, matches the provided Registy.Institutions items. +func validateInstitution(instID string) (bool, error) { + institutions := []Institution{} + if err := param.Registry_Institutions.Unmarshal(&institutions); err != nil { + return false, err + } + // We don't check if config was populated + if len(institutions) == 0 { + return true, nil + } + for _, availableInst := range institutions { + // We required full equality, as we expect the value is from the institution API + if instID == availableInst.ID { + return true, nil + } + } + return false, nil +} diff --git a/server_ui/register_namespace_test.go b/server_ui/register_namespace_test.go index ecd1f84cb..af168867d 100644 --- a/server_ui/register_namespace_test.go +++ b/server_ui/register_namespace_test.go @@ -77,7 +77,7 @@ func TestRegistration(t *testing.T) { require.NotEmpty(t, keyId) //Configure registry - registry.RegisterRegistryRoutes(engine.Group("/")) + registry.RegisterRegistryAPI(engine.Group("/")) //Create a test HTTP server that sends requests to gin svr := httptest.NewServer(engine) diff --git a/web_ui/authentication.go b/web_ui/authentication.go index 62dbfa661..55b4473c2 100644 --- a/web_ui/authentication.go +++ b/web_ui/authentication.go @@ -28,6 +28,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gorilla/csrf" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/pelicanplatform/pelican/config" @@ -172,7 +173,7 @@ func setLoginCookie(ctx *gin.Context, user string) { } // Check if user is authenticated by checking if the "login" cookie is present and set the user identity to ctx -func authHandler(ctx *gin.Context) { +func AuthHandler(ctx *gin.Context) { user, err := getUser(ctx) if err != nil || user == "" { log.Errorln("Invalid user cookie or unable to parse user cookie:", err) @@ -268,14 +269,23 @@ func configureAuthEndpoints(router *gin.Engine) error { log.Infoln("Authorization not configured (non-fatal):", err) } + csrfHandler, err := config.GetCSRFHandler() + if err != nil { + return err + } + group := router.Group("/api/v1.0/auth") group.POST("/login", loginHandler) group.POST("/initLogin", initLoginHandler) - group.POST("/resetLogin", authHandler, resetLoginHandler) - group.GET("/whoami", func(ctx *gin.Context) { + group.POST("/resetLogin", AuthHandler, resetLoginHandler) + // Pass csrfhanlder only to the whoami route to generate CSRF token + // while leaving other routes free of CSRF check (we might want to do it some time in the future) + group.GET("/whoami", csrfHandler, func(ctx *gin.Context) { if user, err := getUser(ctx); err != nil || user == "" { ctx.JSON(200, gin.H{"authenticated": false}) } else { + // Set header to carry CSRF token + ctx.Header("X-CSRF-Token", csrf.Token(ctx.Request)) ctx.JSON(200, gin.H{"authenticated": true, "user": user}) } }) diff --git a/web_ui/frontend/app/api/docs/pelican-swagger.yaml b/web_ui/frontend/app/api/docs/pelican-swagger.yaml index 78a9602f3..40d3ec71d 100644 --- a/web_ui/frontend/app/api/docs/pelican-swagger.yaml +++ b/web_ui/frontend/app/api/docs/pelican-swagger.yaml @@ -1,22 +1,28 @@ -swagger: '2.0' +swagger: "2.0" info: title: Pelican Server APIs - description: "[Pelican](https://pelicanplatform.org/) provides an open-source software platform for federating - dataset repositories together and delivering the objects to computing capacity such as the [OSPool](https://osg-htc.org/services/open_science_pool.html) + description: + "[Pelican](https://pelicanplatform.org/) provides an open-source software platform for federating + dataset repositories together and delivering the objects to computing capacity such as the [OSPool](https://osg-htc.org/services/open_science_pool.html) - This is the API documentation for various APIs in Pelican servers (director, registry, origin, etc) - to communicate with each other and in-between users accessing the servers. - + This is the API documentation for various APIs in Pelican servers (director, registry, origin, etc) + to communicate with each other and in-between users accessing the servers. - For how to set up Pelican servers, please refer to the documentation at [docs.pelicanplatform.org](https://docs.pelicanplatform.org/)" + + Note that we use cookie authentication and authorization. We check a cookie named `login` with value being a JWT. + The cookie is issued after a successful call to `/api/v1.0/auth/login`. However, OpenAPI 2.0 does not support specifying cookie-based security check. + Therefore, as an alternative, we will add `Authentication Required` to the API description where needed. + + + For how to set up Pelican servers, please refer to the documentation at [docs.pelicanplatform.org](https://docs.pelicanplatform.org/)" license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0 contact: name: API Support via Pelican GitHub Issue url: https://github.com/PelicanPlatform/pelican/issues - version: '1.0' + version: "1.0" basePath: /api/v1.0/ consumes: - application/json @@ -36,7 +42,7 @@ definitions: HealthStatus: type: object description: The health status of a server component - properties: + properties: status: type: string description: The status of the component, can be one of "unknown", "warning", "ok", and "critical" @@ -53,36 +59,181 @@ definitions: ErrorModel: type: object description: The error reponse of a request - properties: + properties: error: type: string description: The detail error message - example: Authentication required to perform this operation + example: Bad request SuccessModel: type: object description: The successful reponse of a request - properties: + properties: msg: type: string description: The detail success message example: Success + AdminMetadata: + type: object + properties: + user_id: + type: string + description: '"sub" claim of user JWT who requested registration' + description: + type: string + site_name: + type: string + description: "Name of the site" + institution: + type: string + description: > + "Unique identifier of the institution to register to. + For Pelican running in OSDF mode, this will be the OSG ID of the institution" + example: "https://osg-htc.org/iid/01y2jtd41" + security_contact_user_id: + type: string + description: '"sub" claim of user responsible for the security of the service' + status: + $ref: "#/definitions/RegistrationStatus" + approver_id: + type: string + description: '"sub" claim of user JWT who approved the service registration' + approved_at: + type: string + format: date-time + description: "Timestamp of when the registration was approved" + created_at: + type: string + format: date-time + description: "Timestamp of when the registration was created" + updated_at: + type: string + format: date-time + description: "Timestamp of the last update" + AdminMetadataForRegistration: + type: object + properties: + description: + type: string + site_name: + type: string + description: "Name of the site" + institution: + type: string + description: > + "Unique identifier of the institution to register to. + For Pelican running in OSDF mode, this will be the OSG ID of the institution" + example: "https://osg-htc.org/iid/01y2jtd41" + security_contact_user_id: + type: string + description: '"sub" claim of user responsible for the security of the service' + RegistrationStatus: + type: string + enum: + - Pending + - Approved + - Denied + - Unknown + NamespaceWOPubkey: + type: object + properties: + id: + type: integer + description: The ID of the namespace entry + example: 1 + prefix: + type: string + description: The namespace prefix to register. Should be an absolute path. + example: "/test" + admin_metadata: + $ref: "#/definitions/AdminMetadata" + Institution: + type: object + properties: + id: + type: string + description: The unique ID of the institution. For Pelican running in OSDF alias, this will be OSG ID of the institution + example: https://osg-htc.org/iid/01y2jtd41 + name: + type: string + description: The name of the institution + example: University of Wisconsin - Madison + Namespace: + type: object + properties: + id: + type: integer + description: The ID of the namespace entry + example: 1 + prefix: + type: string + description: The namespace prefix to register. Should be an absolute path. + example: "/test" + pubkey: + type: string + description: + The public JWK from the origin that wants to register the namespace. + It should be a marshalled (stringfied) JSON that contains either one JWK or a JWKS + admin_metadata: + $ref: "#/definitions/AdminMetadata" + NamespaceForRegistration: + type: object + properties: + prefix: + type: string + description: The namespace prefix to register. Should be an obsolute paths + example: "/test" + pubkey: + type: string + description: + The public JWK from the origin that wants to register the namespace. + It should be a marshalled (stringfied) JSON that contains either one JWK or a JWKS + admin_metadata: + $ref: "#/definitions/AdminMetadataForRegistration" + RegistrationFieldType: + type: string + enum: + - string + - int + - enum + - datetime + RegistrationField: + type: object + properties: + name: + type: string + description: The name of the field available to register + example: "prefix" + type: + description: The data type of the field + $ref: "#/definitions/RegistrationFieldType" + required: + description: If this field is required for registration + type: boolean + options: + description: The available options if the field is "enum" type + type: array + items: + type: string + minItems: 0 + tags: - name: auth description: Authentication APIs for all servers - name: common description: Common APIs for all servers + - name: registry_ui + description: APIs for Registry server Web UI paths: /health: get: tags: - common summary: Returns the health status of server components + description: "`Authentication Required`" produces: - application/json - security: - - Bearer: [] responses: - '200': + "200": description: OK schema: type: object @@ -93,41 +244,40 @@ paths: components: type: object description: The health status of each server components - properties: + properties: cmsd: - $ref: '#/definitions/HealthStatus' + $ref: "#/definitions/HealthStatus" federation: - $ref: '#/definitions/HealthStatus' + $ref: "#/definitions/HealthStatus" web-ui: - $ref: '#/definitions/HealthStatus' + $ref: "#/definitions/HealthStatus" xrootd: - $ref: '#/definitions/HealthStatus' + $ref: "#/definitions/HealthStatus" /config: get: tags: - - common + - common summary: Return the configuration values of the server + description: "`Authentication Required`" produces: - - application/json - security: - - Bearer: [] + - application/json responses: - '200': + "200": description: OK schema: type: object description: The JSON object output from viper with all config values in the current server - '401': + "401": description: Unauthorized /auth/login: post: tags: - - auth + - auth summary: Login with username and password to Pelican web UI consumes: - - application/json + - application/json produces: - - application/json + - application/json parameters: - in: body name: userCredential @@ -137,23 +287,23 @@ paths: required: - user - password - properties: + properties: user: type: string password: type: string responses: - '200': + "200": description: Login succeed schema: type: object - $ref: "#/definitions/SuccessModel" - '400': + $ref: "#/definitions/SuccessModel" + "400": description: Invalid request, when username or password is missing schema: type: object - $ref: "#/definitions/ErrorModel" - '401': + $ref: "#/definitions/ErrorModel" + "401": description: Login failed, when username or password doesn't match the record schema: type: object @@ -161,12 +311,12 @@ paths: /auth/initLogin: post: tags: - - auth + - auth summary: Login with one-time activation code to initialize web UI consumes: - - application/json + - application/json produces: - - application/json + - application/json parameters: - in: body name: activationCode @@ -175,23 +325,24 @@ paths: type: object required: - code - properties: + properties: code: type: string example: "123456" responses: - '200': + "200": description: Login succeed schema: type: object - $ref: "#/definitions/SuccessModel" - '400': - description: Invalid request, when authentication is already initialized, + $ref: "#/definitions/SuccessModel" + "400": + description: + Invalid request, when authentication is already initialized, code-based login is not available, or login code is not provided schema: type: object - $ref: "#/definitions/ErrorModel" - '401': + $ref: "#/definitions/ErrorModel" + "401": description: Login failed, when code is not valid schema: type: object @@ -199,14 +350,13 @@ paths: /auth/resetLogin: post: tags: - - auth + - auth summary: Reset the password for the user + description: "`Authentication Required`" consumes: - - application/json + - application/json produces: - - application/json - security: - - Bearer: [] + - application/json parameters: - in: body name: newPassword @@ -215,28 +365,28 @@ paths: type: object required: - password - properties: + properties: password: type: string description: The new password to reset to example: "" responses: - '200': + "200": description: Reset succeed schema: type: object - $ref: "#/definitions/SuccessModel" - '400': + $ref: "#/definitions/SuccessModel" + "400": description: Invalid request request, when password is missing schema: type: object - $ref: "#/definitions/ErrorModel" - '500': + $ref: "#/definitions/ErrorModel" + "500": description: Server-side error, when failed to write the new password to auth file schema: type: object - $ref: "#/definitions/ErrorModel" - '403': + $ref: "#/definitions/ErrorModel" + "403": description: Unauthorized request, when user is not logged in schema: type: object @@ -244,18 +394,19 @@ paths: /auth/whoami: get: tags: - - auth + - auth summary: Return the authentication status of the web ui produces: - - application/json + - application/json responses: - '200': + "200": description: OK schema: type: object - description: The authentication status and username, if any. "user" field is omitted + description: + The authentication status and username, if any. "user" field is omitted when "authenticated" is false - properties: + properties: authenticated: type: boolean example: true @@ -265,19 +416,403 @@ paths: /auth/loginInitialized: get: tags: - - auth + - auth summary: Return the status of web UI initialization - description: The initialization depends on if the user has used the one-time activation + description: + The initialization depends on if the user has used the one-time activation code to set up the password for the admin user produces: - - application/json + - application/json responses: - '200': + "200": description: OK schema: type: object description: The initialization status - properties: + properties: initialized: type: boolean - example: true \ No newline at end of file + example: true + /registry_ui/namespaces: + get: + tags: + - "registry_ui" + summary: Return a list of all namespaces in the registry + description: A public API to get all namespaces in the registry + parameters: + - name: server_type + in: query + required: false + description: The type of server to filter the results. The value can be either "origin" or "cache" + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + type: array + items: + $ref: "#/definitions/NamespaceWOPubkey" + description: An array of namespaces + "400": + description: Invalid request parameters + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + options: + tags: + - "registry_ui" + summary: Return a list of field available to register + description: "`Authentication Required`" + produces: + - application/json + responses: + "200": + description: OK + schema: + type: array + items: + $ref: "#/definitions/RegistrationField" + post: + tags: + - "registry_ui" + summary: Create a new namespace registration + description: "`Authentication Required`" + consumes: + - application/json + produces: + - application/json + parameters: + - in: body + name: namespace + description: The namespace data to register + required: true + schema: + $ref: "#/definitions/NamespaceForRegistration" + responses: + "200": + description: OK + schema: + type: object + $ref: "#/definitions/SuccessModel" + "400": + description: The request data has invalid or missing field value + schema: + type: object + $ref: "#/definitions/ErrorModel" + "401": + description: Unauthorized + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + /registry_ui/namespaces/user: + get: + tags: + - "registry_ui" + summary: Return a list of namespaces for the currently authenticated user + description: "`Authentication Required`" + produces: + - application/json + responses: + "200": + description: OK + schema: + type: array + items: + $ref: "#/definitions/NamespaceWOPubkey" + description: An array of namespaces + "400": + description: Invalid request parameters + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + /registry_ui/namespaces/{id}: + get: + tags: + - "registry_ui" + summary: Return the namespace by `id` + description: "`Authentication Required` + + + For user with admin previlege, it returns for all valid namespace request. + + + For general users, it only returns namespace belonging to the user, or it returns 404 + " + operationId: getNamespaceById + parameters: + - name: id + in: path + description: ID of the namespace to fetch + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + $ref: "#/definitions/Namespace" + "400": + description: Invalid namespace ID + schema: + type: object + $ref: "#/definitions/ErrorModel" + "401": + description: Authentication required to perform this action + schema: + type: object + $ref: "#/definitions/ErrorModel" + "404": + description: Namespace not found, either does not exists or the user doesn't have previlege to get it + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + put: + tags: + - "registry_ui" + summary: Update the namespace by `id` + description: "`Authentication Required` + + + For user with admin previlege, they can update any valid namespace. + + + For general users, they can only update the namespace belonging to the user, or it returns 404 + " + operationId: updateNamespaceById + parameters: + - name: id + in: path + description: ID of the namespace to update + required: true + type: integer + - in: body + name: namespace + description: The namespace data to update + required: true + schema: + $ref: "#/definitions/NamespaceForRegistration" + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + $ref: "#/definitions/SuccessModel" + "400": + description: Invalid namespace ID + schema: + type: object + $ref: "#/definitions/ErrorModel" + "401": + description: Authentication required to perform this action + schema: + type: object + $ref: "#/definitions/ErrorModel" + "403": + description: The user does not have previlege to update the namespace + schema: + type: object + $ref: "#/definitions/ErrorModel" + "404": + description: Namespace not found because it does not exist + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + /registry_ui/namespaces/{id}/pubkey: + get: + tags: + - "registry_ui" + summary: Returns the public key of the namespace by id, in JWK Set format + description: It returns the JWK set as a downloadable attachement. Refer to https://datatracker.ietf.org/doc/html/rfc7517#section-5 for the format of JWK set + operationId: getNamespacePubkeyById + parameters: + - name: id + in: path + description: ID of the namespace to get public key + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK, an attachement is returned to download + schema: + type: object + "400": + description: Invalid namespace ID + schema: + type: object + $ref: "#/definitions/ErrorModel" + "404": + description: Namespace not found, either does not exist or the user doesn't have previlege to get it + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + /registry_ui/namespaces/{id}/approve: + patch: + tags: + - "registry_ui" + summary: Update namespace status to "approved" + description: "`Authentication Required` + + + Update namespace status to `approved` by namespace `id`. + + + This action requires admin previlege to perform. + " + parameters: + - name: id + in: path + description: ID of the namespace to update status + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: object + $ref: "#/definitions/SuccessModel" + "400": + description: Invalid namespace ID + schema: + type: object + $ref: "#/definitions/ErrorModel" + "401": + description: Authentication required to perform this action + schema: + type: object + $ref: "#/definitions/ErrorModel" + "403": + description: The user does not have previlege to update the namespace status + schema: + type: object + $ref: "#/definitions/ErrorModel" + "404": + description: Namespace not found because it does not exist + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + /registry_ui/namespaces/{id}/deny: + patch: + tags: + - "registry_ui" + summary: Update namespace status to "denied" + description: "`Authentication Required` + + + Update namespace status to `denied` by namespace `id`. + + + This action requires admin previlege to perform. + " + parameters: + - name: id + in: path + description: ID of the namespace to update status + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: object + $ref: "#/definitions/SuccessModel" + "400": + description: Invalid namespace ID + schema: + type: object + $ref: "#/definitions/ErrorModel" + "401": + description: Authentication required to perform this action + schema: + type: object + $ref: "#/definitions/ErrorModel" + "403": + description: The user does not have previlege to update the namespace status + schema: + type: object + $ref: "#/definitions/ErrorModel" + "404": + description: Namespace not found because it does not exist + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Internal server error + schema: + type: object + $ref: "#/definitions/ErrorModel" + /registry_ui/institutions: + get: + tags: + - "registry_ui" + summary: Returns a list of institution names available for user to select for namespace registration + description: "`Authentication Required`" + produces: + - application/json + responses: + "200": + description: OK + schema: + type: array + items: + type: object + $ref: "#/definitions/Institution" + minItems: 0 + "401": + description: Authentication required to perform this action + schema: + type: object + $ref: "#/definitions/ErrorModel" + "500": + description: Server didn't configure `Registry.Institutions` or server encoutered error in reading institution configuration + schema: + type: object + $ref: "#/definitions/ErrorModel" diff --git a/web_ui/oauth2_client.go b/web_ui/oauth2_client.go index 4bca63460..1534f3f8e 100644 --- a/web_ui/oauth2_client.go +++ b/web_ui/oauth2_client.go @@ -178,14 +178,7 @@ func handleOAuthCallback(ctx *gin.Context) { return } - userIdentifier := "" - if userInfo.Email != "" { - userIdentifier = userInfo.Email - } else if userInfo.SubID != "" { - userIdentifier = userInfo.SubID - } else { - userIdentifier = userInfo.Sub - } + userIdentifier := userInfo.Sub if userIdentifier == "" { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error setting login cookie: can't find valid user id from CILogon"}) return diff --git a/web_ui/ui.go b/web_ui/ui.go index db542383b..b6b4eea98 100644 --- a/web_ui/ui.go +++ b/web_ui/ui.go @@ -137,7 +137,7 @@ func configureWebResource(engine *gin.Engine) error { // Configure common endpoint available to all server web UI which are located at /api/v1.0/* func configureCommonEndpoints(engine *gin.Engine) error { - engine.GET("/api/v1.0/config", authHandler, getConfigValues) + engine.GET("/api/v1.0/config", AuthHandler, getConfigValues) return nil } @@ -155,7 +155,7 @@ func configureMetrics(engine *gin.Engine) error { prometheusMonitor := ginprometheus.NewPrometheus("gin") prometheusMonitor.Use(engine) - engine.GET("/api/v1.0/health", authHandler, func(ctx *gin.Context) { + engine.GET("/api/v1.0/health", AuthHandler, func(ctx *gin.Context) { healthStatus := metrics.GetHealthStatus() ctx.JSON(http.StatusOK, healthStatus) })