diff --git a/api/handlers.go b/api/handlers.go index 0c9362688..e140b2a4f 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -21,13 +21,20 @@ import ( // * "dbname" is the name of the database func branchesHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info - _, dbOwner, dbName, _, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, _, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "branches") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Retrieve the branch list for the database brList, err := com.BranchListResponse(dbOwner, dbFolder, dbName) if err != nil { @@ -59,7 +66,7 @@ func branchesHandler(w http.ResponseWriter, r *http.Request) { // * "table" is the name of the table or view func columnsHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info, open the database - sdb, httpStatus, err := collectInfoAndOpen(w, r) + sdb, httpStatus, err := collectInfoAndOpen(w, r, "columns") if err != nil { jsonErr(w, err.Error(), httpStatus) return @@ -117,13 +124,20 @@ func columnsHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func commitsHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info - _, dbOwner, dbName, _, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, _, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "commits") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Retrieve the commits commits, err := com.GetCommitList(dbOwner, dbFolder, dbName) if err != nil { @@ -147,7 +161,16 @@ func commitsHandler(w http.ResponseWriter, r *http.Request) { // * "apikey" is one of your API keys. These can be generated from your Settings page once logged in func databasesHandler(w http.ResponseWriter, r *http.Request) { // Authenticate the request - loggedInUser, err := checkAuth(w, r) + loggedInUser, apiKey, err := checkAuth(w, r) + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + + // Make sure the API key has permission to run this function on the requested database + // TODO: We probably need a special case for handling the Databases(), Releases(), and Tags() functions. + // Maybe set the dbName value here to a magic value, which permissionCheck() looks for? + err = permissionCheck(loggedInUser, apiKey, "what should we do here", "databases") if err != nil { jsonErr(w, err.Error(), http.StatusUnauthorized) return @@ -184,7 +207,7 @@ func databasesHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func deleteHandler(w http.ResponseWriter, r *http.Request) { // Authenticate the request - loggedInUser, err := checkAuth(w, r) + loggedInUser, apiKey, err := checkAuth(w, r) if err != nil { jsonErr(w, err.Error(), http.StatusUnauthorized) return @@ -199,6 +222,13 @@ func deleteHandler(w http.ResponseWriter, r *http.Request) { dbOwner := loggedInUser dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "delete") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Invalidate the memcache data for the database err = com.InvalidateCacheEntry(loggedInUser, dbOwner, dbFolder, dbName, "") // Empty string indicates "for all versions" if err != nil { @@ -237,7 +267,7 @@ func deleteHandler(w http.ResponseWriter, r *http.Request) { // * "merge" specifies the merge strategy (possible values: "none", "preserve_pk", "new_pk"; optional, defaults to "none") // * "include_data" can be set to "1" to include the full data of all changed rows instead of just the primary keys (optional, defaults to 0) func diffHandler(w http.ResponseWriter, r *http.Request) { - loggedInUser, err := checkAuth(w, r) + loggedInUser, apiKey, err := checkAuth(w, r) if err != nil { jsonErr(w, err.Error(), http.StatusUnauthorized) return @@ -334,6 +364,18 @@ func diffHandler(w http.ResponseWriter, r *http.Request) { return } + // Make sure the API key has permission to run this function on the requested databases + err = permissionCheck(loggedInUser, apiKey, dbNameA, "diff") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + err = permissionCheck(loggedInUser, apiKey, dbNameB, "diff") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Perform diff diffs, err := com.Diff(dbOwnerA, "/", dbNameA, ca, dbOwnerB, "/", dbNameB, cb, loggedInUser, mergeStrategy, includeData) if err != nil { @@ -359,13 +401,20 @@ func diffHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func downloadHandler(w http.ResponseWriter, r *http.Request) { // Authenticate user and collect requested database details - loggedInUser, dbOwner, dbName, commitID, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, commitID, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "download") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Return the requested database to the user _, err = com.DownloadDatabase(w, r, dbOwner, dbFolder, dbName, commitID, loggedInUser, "api") if err != nil { @@ -382,7 +431,7 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func indexesHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info, open the database - sdb, httpStatus, err := collectInfoAndOpen(w, r) + sdb, httpStatus, err := collectInfoAndOpen(w, r, "indexes") if err != nil { jsonErr(w, err.Error(), httpStatus) return @@ -436,13 +485,20 @@ func indexesHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func metadataHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info - _, dbOwner, dbName, _, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, _, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "metadata") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Retrieve the metadata for the database meta, err := com.MetadataResponse(dbOwner, dbFolder, dbName) if err != nil { @@ -472,7 +528,7 @@ func metadataHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database // * "sql" is the SQL query to run, base64 encoded func queryHandler(w http.ResponseWriter, r *http.Request) { - loggedInUser, err := checkAuth(w, r) + loggedInUser, apiKey, err := checkAuth(w, r) if err != nil { jsonErr(w, err.Error(), http.StatusUnauthorized) return @@ -486,6 +542,13 @@ func queryHandler(w http.ResponseWriter, r *http.Request) { } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "query") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Grab the incoming SQLite query rawInput := r.FormValue("sql") decodedStr, err := com.CheckUnicode(rawInput) @@ -532,13 +595,22 @@ func queryHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func releasesHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info - _, dbOwner, dbName, _, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, _, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + // TODO: We probably need a special case for handling the Databases(), Releases(), and Tags() functions. + // Maybe set the dbName value here to a magic value, which permissionCheck() looks for? + err = permissionCheck(loggedInUser, apiKey, "what should we do here", "releases") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Retrieve the list of releases rels, err := com.GetReleases(dbOwner, dbFolder, dbName) if err != nil { @@ -590,7 +662,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func tablesHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info, open the database - sdb, httpStatus, err := collectInfoAndOpen(w, r) + sdb, httpStatus, err := collectInfoAndOpen(w, r, "tables") if err != nil { jsonErr(w, err.Error(), httpStatus) return @@ -622,13 +694,22 @@ func tablesHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database func tagsHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info - _, dbOwner, dbName, _, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, _, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + // TODO: We probably need a special case for handling the Databases(), Releases(), and Tags() functions. + // Maybe set the dbName value here to a magic value, which permissionCheck() looks for? + err = permissionCheck(loggedInUser, apiKey, "what should we do here", "tags") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Retrieve the tags tags, err := com.GetTags(dbOwner, dbFolder, dbName) if err != nil { @@ -669,7 +750,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, com.MaxDatabaseSize*1024*1024) // Authenticate the request - loggedInUser, err := checkAuth(w, r) + loggedInUser, apiKey, err := checkAuth(w, r) if err != nil { jsonErr(w, err.Error(), http.StatusUnauthorized) return @@ -682,6 +763,13 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { return } + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "upload") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // The "public" user isn't allowed to make changes if loggedInUser == "public" { log.Printf("User from '%s' attempted to add a database using the public certificate", r.RemoteAddr) @@ -745,7 +833,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database being queried func viewsHandler(w http.ResponseWriter, r *http.Request) { // Do auth check, grab request info, open the database - sdb, httpStatus, err := collectInfoAndOpen(w, r) + sdb, httpStatus, err := collectInfoAndOpen(w, r, "views") if err != nil { jsonErr(w, err.Error(), httpStatus) return @@ -777,13 +865,20 @@ func viewsHandler(w http.ResponseWriter, r *http.Request) { // * "dbname" is the name of the database being queried func webpageHandler(w http.ResponseWriter, r *http.Request) { // Authenticate user and collect requested database details - _, dbOwner, dbName, _, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, _, httpStatus, err := collectInfo(w, r) if err != nil { jsonErr(w, err.Error(), httpStatus) return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, "webpage") + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Return the database webUI URL to the user var z com.WebpageResponseContainer z.WebPage = "https://" + com.Conf.Web.ServerName + "/" + dbOwner + dbFolder + dbName diff --git a/api/main.go b/api/main.go index b0c6de321..694e90188 100644 --- a/api/main.go +++ b/api/main.go @@ -151,12 +151,22 @@ func main() { } // checkAuth authenticates and logs the incoming request -func checkAuth(w http.ResponseWriter, r *http.Request) (loggedInUser string, err error) { +func checkAuth(w http.ResponseWriter, r *http.Request) (loggedInUser, apiKey string, err error) { // Extract the API key from the request - apiKey := r.FormValue("apikey") + a := r.FormValue("apikey") + + // Check if an API key was provided + if a != "" { + // Validate the API key + err = com.CheckAPIKey(a) + if err != nil { + err = fmt.Errorf("Incorrect or unknown API key and certificate") + return + } + + // API key passed validation + apiKey = a - // Check if API key was provided - if apiKey != "" { // Look up the owner of the API key loggedInUser, err = com.GetAPIKeyUser(apiKey) } else { @@ -170,6 +180,12 @@ func checkAuth(w http.ResponseWriter, r *http.Request) (loggedInUser string, err return } + // If the client authenticated through their certificate instead of an API key, then we need to pass + // that information along for special handling + if apiKey == "" { + apiKey = "clientcert" + } + // Log the incoming request logReq(r, loggedInUser) return @@ -177,11 +193,11 @@ func checkAuth(w http.ResponseWriter, r *http.Request) (loggedInUser string, err // collectInfo is an internal function which: // 1. Authenticates incoming requests -// 2. Extracts the database owner, name, and commit ID from the request +// 2. Extracts the database owner, name, api key, and commit ID from the request // 3. Checks permissions -func collectInfo(w http.ResponseWriter, r *http.Request) (loggedInUser, dbOwner, dbName, commitID string, httpStatus int, err error) { +func collectInfo(w http.ResponseWriter, r *http.Request) (loggedInUser, dbOwner, dbName, apiKey, commitID string, httpStatus int, err error) { // Authenticate the request - loggedInUser, err = checkAuth(w, r) + loggedInUser, apiKey, err = checkAuth(w, r) if err != nil { httpStatus = http.StatusUnauthorized return @@ -215,15 +231,22 @@ func collectInfo(w http.ResponseWriter, r *http.Request) (loggedInUser, dbOwner, // 2. Fetches the database from Minio // 3. Opens the database, returning the connection handle // This function exists purely because this code is common to most of the handlers -func collectInfoAndOpen(w http.ResponseWriter, r *http.Request) (sdb *sqlite.Conn, httpStatus int, err error) { +func collectInfoAndOpen(w http.ResponseWriter, r *http.Request, permName com.APIPermission) (sdb *sqlite.Conn, httpStatus int, err error) { // Authenticate the request and collect details for the requested database - loggedInUser, dbOwner, dbName, commitID, httpStatus, err := collectInfo(w, r) + loggedInUser, dbOwner, dbName, apiKey, commitID, httpStatus, err := collectInfo(w, r) if err != nil { httpStatus = http.StatusInternalServerError return } dbFolder := "/" + // Make sure the API key has permission to run this function on the requested database + err = permissionCheck(loggedInUser, apiKey, dbName, permName) + if err != nil { + jsonErr(w, err.Error(), http.StatusUnauthorized) + return + } + // Get Minio bucket bucket, id, _, err := com.MinioLocation(dbOwner, dbFolder, dbName, commitID, loggedInUser) if err != nil { @@ -337,3 +360,31 @@ func logReq(r *http.Request, loggedInUser string) { loggedInUser, time.Now().Format(time.RFC3339Nano), r.Method, r.URL, r.Proto, r.Referer(), r.Header.Get("User-Agent")) } + +// permissionCheck checks if a given incoming api key request is allowed to run on the requested database +func permissionCheck(loggedInUser, apiKey, dbName string, permName com.APIPermission) (err error) { + // Retrieve the permission details for the api key + var apiDetails com.APIKey + apiDetails, err = com.APIKeyPerms(loggedInUser, apiKey) + if err != nil { + return + } + + // If the user authenticated using their client certificate, we skip the permission checks as they have + // full access to all their databases + if apiKey != "clientcert" { + // Ensure the database name matches + // TODO: We probably need a special case for handling the Databases(), Releases(), and Tags() functions. + if apiDetails.Database != dbName && apiDetails.Database != "" { // Empty string in the database means "All databases allowed" + err = fmt.Errorf("Permission denied") + return + } + + // Ensure the required function permission has been granted + if apiDetails.Permissions[permName] != true { + err = fmt.Errorf("Permission denied") + return + } + } + return +} diff --git a/api/main_test.go b/api/main_test.go new file mode 100644 index 000000000..18a6a8833 --- /dev/null +++ b/api/main_test.go @@ -0,0 +1,12 @@ +package main + +// Initial tests for the API functions +// * These are designed to run in a Docker container, with the DBHub.io +// daemons running in the same container (listening on localhost), with +// the testing database loaded + +import "testing" + +func TestSomething(t *testing.T) { + // TBD +} \ No newline at end of file diff --git a/common/postgresql.go b/common/postgresql.go index 41fbb66ab..7df38f1fc 100644 --- a/common/postgresql.go +++ b/common/postgresql.go @@ -96,7 +96,177 @@ func AddUser(auth0ID, userName, password, email, displayName, avatarURL string) return nil } +// APIKeyDBSave changes which database an API key applies to +func APIKeyDBSave(loggedInUser, apiKey, dbName string, allDB bool) error { + var dbID pgx.NullInt64 + var err error + + // If this api key applies to "all databases", then we store null in its db_id field + if allDB != true { + var d int + d, err = databaseID(loggedInUser, "/", dbName) + if err != nil { + log.Printf("Retrieving database ID failed: %v\n", err) + return err + } + dbID.Int64 = int64(d) + dbID.Valid = true + } + + // Store the updated database + dbQuery := ` + WITH uid AS ( + SELECT user_id + FROM users + WHERE user_name = $1 + ), key_info AS ( + SELECT key_id + FROM api_keys, uid + WHERE api_keys.user_id = uid.user_id + AND key = $2 + ) + INSERT INTO api_permissions (key_id, user_id, db_id) + SELECT (SELECT key_id FROM key_info), (SELECT user_id FROM uid), $3 + ON CONFLICT (user_id, key_id) + DO UPDATE + SET db_id = $3` + commandTag, err := pdb.Exec(dbQuery, loggedInUser, apiKey, dbID) + if err != nil { + log.Printf("Updating database for API key '%v' failed: %v\n", apiKey, err) + return err + } + if numRows := commandTag.RowsAffected(); numRows != 1 { + log.Printf("Wrong number of rows (%d) affected when updating API key '%v' database \n", numRows, apiKey) + } + return nil +} + +// APIKeyPerms returns the permission details of an API key +func APIKeyPerms(loggedInUser, apiKey string) (apiDetails APIKey, err error) { + // TODO: The multiple SQL queries below are probably do-able with a single query, except I'm not real awake atm. + // So will just make it work like this for now. + var keyID pgx.NullInt64 + dbQuery := ` + SELECT key_id + FROM api_keys + WHERE key = $1` + err = pdb.QueryRow(dbQuery, apiKey).Scan(&keyID) + if err != nil { + log.Printf("Fetching API key ID failed: %v\n", err) + } + + var dbID pgx.NullInt64 + dbQuery = ` + SELECT db_id, permissions + FROM api_permissions + WHERE key_id = $1` + err = pdb.QueryRow(dbQuery, keyID).Scan(&dbID, &apiDetails.Permissions) + if err != nil && err != pgx.ErrNoRows { + log.Printf("Fetching database ID and permissions failed: %v\n", err) + return + } + + // If no results were returned, it means no permissions have been set for this api key yet, so use the default of + // "everything enabled" + if err == pgx.ErrNoRows { + // Return "All databases" and "all permissions enabled" + apiDetails.Permissions = APIKeyPermDefaults() + err = nil + return + } + + // If a database ID was returned then look up the database name + if dbID.Valid { + dbQuery = ` + SELECT db.db_name + FROM sqlite_databases db + WHERE db.db_id = $1` + err = pdb.QueryRow(dbQuery, dbID).Scan(&apiDetails.Database) + if err != nil { + log.Printf("Fetching database name failed: %v\n", err) + } + } + + // Just for safety, in case something weird is happening + if apiDetails.Permissions == nil { + // Not sure this case would ever be hit? It would mean there is a database assigned to the api key, but no + // permissions. In theory, that shouldn't be able to happen. Maybe set some defaults here, just in case? + apiDetails.Permissions = APIKeyPermDefaults() + log.Printf("Unexpected weirdness with API key permissions. The api key '%v' has a database set, but no permissions\n", apiKey) + return + } + return +} + +// APIKeyPermSave updates the permissions for an API key +func APIKeyPermSave(loggedInUser, apiKey string, perm APIPermission, value bool) error { + // Data structure for holding the API permission values + permData := make(map[APIPermission]bool) + + // Retrieve the existing API key permissions + dbQuery := ` + WITH uid AS ( + SELECT user_id + FROM users + WHERE user_name = $1 + ), key_info AS ( + SELECT key_id + FROM api_keys, uid + WHERE api_keys.user_id = uid.user_id + AND key = $2 + ) + SELECT permissions + FROM api_permissions, uid, key_info + WHERE api_permissions.user_id = uid.user_id + AND api_permissions.key_id = key_info.key_id` + err := pdb.QueryRow(dbQuery, loggedInUser, apiKey).Scan(&permData) + if err != nil { + // Returning no rows is ok for this call + if err != pgx.ErrNoRows { + log.Printf("Fetching API key permissions failed: %v\n", err) + return err + } + } + + // If there isn't any permission data for the API key, it means the key was generated before permissions were + // available. So, we default to "all databases" and "all permissions are turned on" + if len(permData) == 0 { + permData = APIKeyPermDefaults() + } + + // Incorporate the updated permission data from the user + permData[perm] = value + + // Store the updated permissions + dbQuery = ` + WITH uid AS ( + SELECT user_id + FROM users + WHERE user_name = $1 + ), key_info AS ( + SELECT key_id + FROM api_keys, uid + WHERE api_keys.user_id = uid.user_id + AND key = $2 + ) + INSERT INTO api_permissions (key_id, user_id, permissions) + SELECT (SELECT key_id FROM key_info), (SELECT user_id FROM uid), $3 + ON CONFLICT (user_id, key_id) + DO UPDATE + SET permissions = $3` + commandTag, err := pdb.Exec(dbQuery, loggedInUser, apiKey, permData) + if err != nil { + log.Printf("Updating permissions for API key '%v' failed: %v\n", apiKey, err) + return err + } + if numRows := commandTag.RowsAffected(); numRows != 1 { + log.Printf("Wrong number of rows (%d) affected when updating API key: %v permissions\n", numRows, apiKey) + } + return nil +} + // APIKeySave saves a new API key to the PostgreSQL database +// TODO: Add the chosen database and permissions func APIKeySave(key, loggedInUser string, dateCreated time.Time) error { // Make sure the API key isn't already in the database dbQuery := ` @@ -1907,7 +2077,11 @@ func GetBranches(dbOwner, dbFolder, dbName string) (branches map[string]BranchEn } // GetAPIKeys returns the list of API keys for a user -func GetAPIKeys(user string) ([]APIKey, error) { +func GetAPIKeys(user string) (apiKeys map[string]APIKey, err error) { + // TODO: Do this as one query, probably using an outer join + + // Get the API key(s) and their creation dates + apiKeys = make(map[string]APIKey) dbQuery := ` SELECT key, date_created FROM api_keys @@ -1916,24 +2090,62 @@ func GetAPIKeys(user string) ([]APIKey, error) { FROM users WHERE lower(user_name) = lower($1) )` - rows, err := pdb.Query(dbQuery, user) + var rows *pgx.Rows + defer func() { + if rows != nil { + rows.Close() + } + }() + rows, err = pdb.Query(dbQuery, user) if err != nil { log.Printf("Database query failed: %v\n", err) - return nil, err + return } defer rows.Close() - var keys []APIKey for rows.Next() { var key string var dateCreated time.Time err = rows.Scan(&key, &dateCreated) if err != nil { log.Printf("Error retrieving API key list: %v\n", err) - return nil, err + return } - keys = append(keys, APIKey{Key: key, DateCreated: dateCreated}) + apiKeys[key] = APIKey{Key: key, DateCreated: dateCreated} } - return keys, nil + + // Get the database and permissions for each key, if it exists + for key, details := range apiKeys { + dbQuery = ` + SELECT db.db_name, perms.permissions + FROM api_keys api + INNER JOIN api_permissions perms ON api.key_id = perms.key_id + INNER JOIN sqlite_databases db ON db.db_id = perms.db_id + WHERE api.user_id = ( + SELECT user_id + FROM users + WHERE lower(user_name) = lower($1) + ) + AND perms.key_id = ( + SELECT key_id + FROM api_keys + WHERE key = $2)` + var dbName pgx.NullString + var perms map[APIPermission]bool + err = pdb.QueryRow(dbQuery, user, key).Scan(&dbName, &perms) + if err != nil && err != pgx.ErrNoRows { + log.Printf("Error retrieving API key permissions: %v\n", err) + return + } + + // If there aren't (yet) any permissions saved for the api key, we enable everything by default + if err == pgx.ErrNoRows || perms == nil { + perms = APIKeyPermDefaults() + err = nil + } + + apiKeys[key] = APIKey{Key: key, DateCreated: details.DateCreated, Database: dbName.String, Permissions: perms} + } + return } // GetAPIKeyUser returns the owner of a given API key. Returns an empty string if the key has no known owner diff --git a/common/types.go b/common/types.go index 589778cdd..26d9e7dec 100644 --- a/common/types.go +++ b/common/types.go @@ -237,10 +237,33 @@ type APIJSONIndex struct { // APIKey is an internal structure used for passing around user API keys type APIKey struct { - Key string `json:"key"` - DateCreated time.Time `json:"date_created"` + Database string `json:"database_name"` + DateCreated time.Time `json:"date_created"` + Key string `json:"key"` + Permissions map[APIPermission]bool `json:"permissions"` } +type APIPermission string + +const ( + APIPermBranches = "branches" + APIPermColumns = "columns" + APIPermCommits = "commits" + APIPermDatabases = "databases" + APIPermDelete = "delete" + APIPermDiff = "diff" + APIPermDownload = "download" + APIPermIndexes = "indexes" + APIPermMetadata = "metadata" + APIPermQuery = "query" + APIPermReleases = "releases" + APIPermTables = "tables" + APIPermTags = "tags" + APIPermUpload = "upload" + APIPermViews = "views" + APIPermWebpage = "webpage" +) + type Auth0Set struct { CallbackURL string ClientID string diff --git a/common/userinput.go b/common/userinput.go index 3019ae38e..28919d2dc 100644 --- a/common/userinput.go +++ b/common/userinput.go @@ -11,8 +11,17 @@ import ( "strings" "unicode" "unicode/utf8" + + "github.com/segmentio/ksuid" ) +// CheckAPIKey checks if a given string is a valid API key +func CheckAPIKey(apiKey string) (err error) { + // Validate the API key + _, err = ksuid.Parse(apiKey) + return +} + // CheckUnicode checks if a given string is unicode, and safe for using in SQLite queries (eg no SQLite control characters) func CheckUnicode(rawInput string) (str string, err error) { var decoded []byte diff --git a/common/util.go b/common/util.go index 80adcd171..1588a90c0 100644 --- a/common/util.go +++ b/common/util.go @@ -423,6 +423,28 @@ func AddDatabase(loggedInUser, dbOwner, dbFolder, dbName string, createBranch bo return numBytes, c.ID, sha, nil } +// APIKeyPermDefaults creates a map with the default API key permissions +func APIKeyPermDefaults() (Perms map[APIPermission]bool) { + Perms = make(map[APIPermission]bool) + Perms[APIPermBranches] = true + Perms[APIPermColumns] = true + Perms[APIPermCommits] = true + Perms[APIPermDatabases] = true + Perms[APIPermDelete] = true + Perms[APIPermDiff] = true + Perms[APIPermDownload] = true + Perms[APIPermIndexes] = true + Perms[APIPermMetadata] = true + Perms[APIPermQuery] = true + Perms[APIPermReleases] = true + Perms[APIPermTables] = true + Perms[APIPermTags] = true + Perms[APIPermUpload] = true + Perms[APIPermViews] = true + Perms[APIPermWebpage] = true + return +} + // CommitPublicFlag returns the public flag of a given commit func CommitPublicFlag(loggedInUser, dbOwner, dbFolder, dbName, commitID string) (public bool, err error) { var DB SQLiteDBinfo diff --git a/database/api_permissions.sql b/database/api_permissions.sql new file mode 100644 index 000000000..4875cb60a --- /dev/null +++ b/database/api_permissions.sql @@ -0,0 +1,47 @@ + +-- *** api_keys tables needed a unique key added to the key_id column *** + +-- +-- Name: api_keys api_keys_key_id; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT api_keys_key_id UNIQUE (key_id); + +-- *** Hang on, this ^^^ seems like it's just a constraint, but no index? Look into it when less sleepy. ;) + + +-- *** New table for holding API key permissions *** + +-- +-- Name: api_permissions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_permissions ( + key_id bigint, + user_id bigint, + db_id bigint, + permissions jsonb +); + +-- +-- Name: api_permissions api_permissions_api_keys_key_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_permissions + ADD CONSTRAINT api_permissions_api_keys_key_id_fk FOREIGN KEY (key_id) REFERENCES public.api_keys(key_id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +-- Name: api_permissions api_permissions_sqlite_databases_db_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_permissions + ADD CONSTRAINT api_permissions_sqlite_databases_db_id_fk FOREIGN KEY (db_id) REFERENCES public.sqlite_databases(db_id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +-- Name: api_permissions api_permissions_users_user_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_permissions + ADD CONSTRAINT api_permissions_users_user_id_fk FOREIGN KEY (user_id) REFERENCES public.users(user_id) ON UPDATE CASCADE ON DELETE CASCADE; + diff --git a/database/dbhub.sql b/database/dbhub.sql index dfb8d3165..f138005a3 100644 --- a/database/dbhub.sql +++ b/database/dbhub.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 10.12 --- Dumped by pg_dump version 10.12 +-- Dumped from database version 12.6 +-- Dumped by pg_dump version 12.6 SET statement_timeout = 0; SET lock_timeout = 0; @@ -17,32 +17,32 @@ SET client_min_messages = warning; SET row_security = off; -- --- Name: permissions; Type: TYPE; Schema: public; Owner: - +-- Name: jsquery; Type: EXTENSION; Schema: -; Owner: - -- -CREATE TYPE public.permissions AS ENUM ( - 'r', - 'rw' -); +CREATE EXTENSION IF NOT EXISTS jsquery WITH SCHEMA public; -- --- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - +-- Name: EXTENSION jsquery; Type: COMMENT; Schema: -; Owner: - -- -CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; +COMMENT ON EXTENSION jsquery IS 'data type for jsonb inspection'; -- --- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: - +-- Name: permissions; Type: TYPE; Schema: public; Owner: - -- -COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; +CREATE TYPE public.permissions AS ENUM ( + 'r', + 'rw' +); SET default_tablespace = ''; -SET default_with_oids = false; +SET default_table_access_method = heap; -- -- Name: api_keys; Type: TABLE; Schema: public; Owner: - @@ -75,6 +75,18 @@ CREATE SEQUENCE public.api_keys_key_id_seq ALTER SEQUENCE public.api_keys_key_id_seq OWNED BY public.api_keys.key_id; +-- +-- Name: api_permissions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_permissions ( + key_id bigint, + user_id bigint, + db_id bigint, + permissions jsonb +); + + -- -- Name: database_downloads; Type: TABLE; Schema: public; Owner: - -- @@ -135,7 +147,7 @@ CREATE TABLE public.database_licences ( display_order integer, lic_id integer NOT NULL, full_name text, - file_format text DEFAULT 'text'::text NOT NULL + file_format text ); @@ -653,6 +665,14 @@ ALTER TABLE ONLY public.users ALTER COLUMN user_id SET DEFAULT nextval('public.u ALTER TABLE ONLY public.vis_query_runs ALTER COLUMN query_run_id SET DEFAULT nextval('public.vis_query_runs_query_run_id_seq'::regclass); +-- +-- Name: api_keys api_keys_key_id; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT api_keys_key_id UNIQUE (key_id); + + -- -- Name: api_keys api_keys_pk; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -962,6 +982,30 @@ ALTER TABLE ONLY public.api_keys ADD CONSTRAINT api_keys_users_user_id_fk FOREIGN KEY (user_id) REFERENCES public.users(user_id) ON UPDATE CASCADE ON DELETE SET NULL; +-- +-- Name: api_permissions api_permissions_api_keys_key_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_permissions + ADD CONSTRAINT api_permissions_api_keys_key_id_fk FOREIGN KEY (key_id) REFERENCES public.api_keys(key_id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: api_permissions api_permissions_sqlite_databases_db_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_permissions + ADD CONSTRAINT api_permissions_sqlite_databases_db_id_fk FOREIGN KEY (db_id) REFERENCES public.sqlite_databases(db_id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: api_permissions api_permissions_users_user_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_permissions + ADD CONSTRAINT api_permissions_users_user_id_fk FOREIGN KEY (user_id) REFERENCES public.users(user_id) ON UPDATE CASCADE ON DELETE CASCADE; + + -- -- Name: database_downloads database_downloads_db_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/webui/main.go b/webui/main.go index 7e72a0eb7..b1ba32bb2 100644 --- a/webui/main.go +++ b/webui/main.go @@ -38,6 +38,108 @@ var ( store *gsm.MemcacheStore ) +// apiKeyDbUpdateHandler handles updating the API key database as requested from the User's Settings page +func apiKeyDbUpdateHandler(w http.ResponseWriter, r *http.Request) { + // Retrieve session data (if any) + loggedInUser, validSession, err := checkLogin(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Ensure we have a valid logged in user + if validSession != true { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Retrieve API key + a := r.PostFormValue("apikey") + apiKey, err := url.QueryUnescape(a) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Validate the API key + err = com.CheckAPIKey(apiKey) + if err != nil { + log.Printf("Validation failed for API key: '%s'- %s", apiKey, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Check if the "all databases" variable was set. If not, get the database name + allDBs := false + var dbName string + z := r.PostFormValue("alldbs") + z2, err := url.QueryUnescape(z) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + switch z2 { + case "true": + allDBs = true + case "false": + // Retrieve the database name + d := r.PostFormValue("dbname") + dbName, err = url.QueryUnescape(d) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Validate the database name + err = com.ValidateDB(dbName) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + default: + // Unknown value passed + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Make sure the given API key has been issued to the user doing the update + keyOwner, err := com.GetAPIKeyUser(apiKey) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if keyOwner != loggedInUser { + log.Printf("Error: attempt by '%v' to change API key permissions for someone else's ('%v') API key", + keyOwner, loggedInUser) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Store the updated database for the API key + err = com.APIKeyDBSave(loggedInUser, apiKey, dbName, allDBs) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // TODO: Return some kind of success flag to the caller + data, err := json.Marshal("Database updated!") + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprint(w, string(data)) +} + // apiKeyGenHandler generates a new API key, stores it in the PG database, and returns the details to the caller func apiKeyGenHandler(w http.ResponseWriter, r *http.Request) { // Retrieve session data (if any) @@ -68,10 +170,15 @@ func apiKeyGenHandler(w http.ResponseWriter, r *http.Request) { // Log the key creation log.Printf("New API key created for user '%s', key: '%s'\n", loggedInUser, key) + // Create a structure holding the default permissions + permData := com.APIKeyPermDefaults() + // Return the API key to the caller d := com.APIKey{ + Database: "", // Default to "all databases" Key: key, DateCreated: creationTime, + Permissions: permData, } data, err := json.Marshal(d) if err != nil { @@ -82,6 +189,141 @@ func apiKeyGenHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, string(data)) } +// apiKeyPermsUpdateHandler handles updating API permissions as requested from the User's Settings page +func apiKeyPermsUpdateHandler(w http.ResponseWriter, r *http.Request) { + // Retrieve session data (if any) + loggedInUser, validSession, err := checkLogin(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Ensure we have a valid logged in user + if validSession != true { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Retrieve API key + a := r.PostFormValue("apikey") + apiKey, err := url.QueryUnescape(a) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Validate the API key + err = com.CheckAPIKey(apiKey) + if err != nil { + log.Printf("Validation failed for API key: '%s'- %s", apiKey, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve and validate the updated permission name + p := r.PostFormValue("perm") + p2, err := url.QueryUnescape(p) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + var perm com.APIPermission + switch strings.ToLower(p2) { + case "branches": + perm = com.APIPermBranches + case "columns": + perm = com.APIPermColumns + case "commits": + perm = com.APIPermCommits + case "databases": + perm = com.APIPermDatabases + case "delete": + perm = com.APIPermDelete + case "diff": + perm = com.APIPermDiff + case "download": + perm = com.APIPermDownload + case "indexes": + perm = com.APIPermIndexes + case "metadata": + perm = com.APIPermMetadata + case "query": + perm = com.APIPermQuery + case "releases": + perm = com.APIPermReleases + case "tables": + perm = com.APIPermTables + case "tags": + perm = com.APIPermTags + case "upload": + perm = com.APIPermUpload + case "views": + perm = com.APIPermViews + case "webpage": + perm = com.APIPermWebpage + default: + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Unknown permission name") + return + } + + // Retrieve updated permission value + v := r.PostFormValue("value") + v2, err := url.QueryUnescape(v) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Validate the provided permission value + value := false + switch strings.ToLower(v2) { + case "true": + value = true + case "false": + value = false + default: + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Invalid true/false value") + return + } + + // Make sure the given API key has been issued to the user doing the update + keyOwner, err := com.GetAPIKeyUser(apiKey) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if keyOwner != loggedInUser { + log.Printf("Error: attempt by '%v' to change API key permissions for someone else's ('%v') API key", + keyOwner, loggedInUser) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Store the updated permissions in the database + err = com.APIKeyPermSave(loggedInUser, apiKey, perm, value) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // TODO: Return success info to the caller + data, err := json.Marshal("Updated permissions saved!") + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprint(w, string(data)) +} + // auth0CallbackHandler is called at the end of the Auth0 authentication process, whether successful or not. // If the authentication process was successful: // * if the user already has an account on our system then this function creates a login session for them. @@ -3096,7 +3338,9 @@ func main() { http.Handle("/upload/", gz.GzipHandler(logReq(uploadPage))) http.Handle("/vis/", gz.GzipHandler(logReq(visualisePage))) http.Handle("/watchers/", gz.GzipHandler(logReq(watchersPage))) + http.Handle("/x/apikeydbupdate", gz.GzipHandler(logReq(apiKeyDbUpdateHandler))) http.Handle("/x/apikeygen", gz.GzipHandler(logReq(apiKeyGenHandler))) + http.Handle("/x/apikeypermupdate", gz.GzipHandler(logReq(apiKeyPermsUpdateHandler))) http.Handle("/x/branchnames", gz.GzipHandler(logReq(branchNamesHandler))) http.Handle("/x/callback", gz.GzipHandler(logReq(auth0CallbackHandler))) http.Handle("/x/checkname", gz.GzipHandler(logReq(checkNameHandler))) diff --git a/webui/pages.go b/webui/pages.go index d6d6b53c5..9d6097fd3 100644 --- a/webui/pages.go +++ b/webui/pages.go @@ -1892,8 +1892,9 @@ func mergePage(w http.ResponseWriter, r *http.Request) { // Renders the user Settings page. func prefPage(w http.ResponseWriter, r *http.Request, loggedInUser string) { var pageData struct { - APIKeys []com.APIKey + APIKeys map[string]com.APIKey Auth0 com.Auth0Set + DBNames []string DisplayName string Email string MaxRows int @@ -1935,6 +1936,25 @@ func prefPage(w http.ResponseWriter, r *http.Request, loggedInUser string) { return } + // The API keys with no database value set should have their text say "All databases" + for key, j := range pageData.APIKeys { + if j.Database == "" { + tmp := pageData.APIKeys[key] + tmp.Database = "All databases" + pageData.APIKeys[key] = tmp + } + } + + // Create the list of databases belonging to the user + dbList, err := com.UserDBs(loggedInUser, com.DB_BOTH) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, "Retrieving list of databases failed") + return + } + for _, db := range dbList { + pageData.DBNames = append(pageData.DBNames, db.Database) + } + // Add Auth0 info to the page data pageData.Auth0 = collectPageAuth0Info() diff --git a/webui/templates/preferences.html b/webui/templates/preferences.html index c524d3aba..ed996e521 100644 --- a/webui/templates/preferences.html +++ b/webui/templates/preferences.html @@ -6,10 +6,10 @@ [[ template "header" . ]]
-
+
 
-
+

Settings

Used when uploading databases

@@ -47,16 +47,172 @@

A Key Generation date + Database(s) + Allowed function calls - - {{ row.key }} - {{ row.date_created | date : 'medium' }} + +

{{ api.key }}

+
+ +
+ + {{ api.date_created | date : 'medium' }} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Branches() +
+ + +
+
Metadata() +
+ + +
+
Columns() +
+ + +
+
Query() +
+ + +
+
Commits() +
+ + +
+
Releases() +
+ + +
+
Databases() +
+ + +
+
Tables() +
+ + +
+
Delete() +
+ + +
+
Tags() +
+ + +
+
Diff() +
+ + +
+
Upload() +
+ + +
+
Download() +
+ + +
+
Views() +
+ + +
+
Indexes() +
+ + +
+
Webpage() +
+ + +
+
+ - You don't have any API keys yet + You don't have any API keys yet - +
@@ -71,18 +227,70 @@

 {{ statusMessage }}

-
+
 
[[ template "footer" . ]] -[[ end ]] \ No newline at end of file +[[ end ]]