Преглед изворни кода

user: add a free text field

Fixes #230
Nicola Murino пре 4 година
родитељ
комит
4bb9d07dde

+ 19 - 1
dataprovider/mysql.go

@@ -38,6 +38,7 @@ const (
 		"ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `unique_mapping` UNIQUE (`user_id`, `folder_id`);" +
 		"ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `folders_mapping_folder_id_fk_folders_id` FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;" +
 		"ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `folders_mapping_user_id_fk_users_id` FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;"
+	mysqlV6SQL = "ALTER TABLE `{{users}}` ADD COLUMN `additional_info` longtext NULL;"
 )
 
 // MySQLProvider auth provider for MySQL/MariaDB database
@@ -217,6 +218,8 @@ func (p MySQLProvider) migrateDatabase() error {
 		return updateMySQLDatabaseFromV3(p.dbHandle)
 	case 4:
 		return updateMySQLDatabaseFromV4(p.dbHandle)
+	case 5:
+		return updateMySQLDatabaseFromV5(p.dbHandle)
 	default:
 		return fmt.Errorf("Database version not handled: %v", dbVersion.Version)
 	}
@@ -247,7 +250,15 @@ func updateMySQLDatabaseFromV3(dbHandle *sql.DB) error {
 }
 
 func updateMySQLDatabaseFromV4(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom4To5(dbHandle)
+	err := updateMySQLDatabaseFrom4To5(dbHandle)
+	if err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV5(dbHandle)
+}
+
+func updateMySQLDatabaseFromV5(dbHandle *sql.DB) error {
+	return updateMySQLDatabaseFrom5To6(dbHandle)
 }
 
 func updateMySQLDatabaseFrom1To2(dbHandle *sql.DB) error {
@@ -271,3 +282,10 @@ func updateMySQLDatabaseFrom3To4(dbHandle *sql.DB) error {
 func updateMySQLDatabaseFrom4To5(dbHandle *sql.DB) error {
 	return sqlCommonUpdateDatabaseFrom4To5(dbHandle)
 }
+
+func updateMySQLDatabaseFrom5To6(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 5 -> 6")
+	providerLog(logger.LevelInfo, "updating database version: 5 -> 6")
+	sql := strings.Replace(mysqlV6SQL, "{{users}}", sqlTableUsers, 1)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 6)
+}

+ 19 - 1
dataprovider/pgsql.go

@@ -37,6 +37,7 @@ ALTER TABLE "{{folders_mapping}}" ADD CONSTRAINT "folders_mapping_user_id_fk_use
 CREATE INDEX "folders_mapping_folder_id_idx" ON "{{folders_mapping}}" ("folder_id");
 CREATE INDEX "folders_mapping_user_id_idx" ON "{{folders_mapping}}" ("user_id");
 `
+	pgsqlV6SQL = `ALTER TABLE "{{users}}" ADD COLUMN "additional_info" text NULL;`
 )
 
 // PGSQLProvider auth provider for PostgreSQL database
@@ -216,6 +217,8 @@ func (p PGSQLProvider) migrateDatabase() error {
 		return updatePGSQLDatabaseFromV3(p.dbHandle)
 	case 4:
 		return updatePGSQLDatabaseFromV4(p.dbHandle)
+	case 5:
+		return updatePGSQLDatabaseFromV5(p.dbHandle)
 	default:
 		return fmt.Errorf("Database version not handled: %v", dbVersion.Version)
 	}
@@ -246,7 +249,15 @@ func updatePGSQLDatabaseFromV3(dbHandle *sql.DB) error {
 }
 
 func updatePGSQLDatabaseFromV4(dbHandle *sql.DB) error {
-	return updatePGSQLDatabaseFrom4To5(dbHandle)
+	err := updatePGSQLDatabaseFrom4To5(dbHandle)
+	if err != nil {
+		return err
+	}
+	return updatePGSQLDatabaseFromV5(dbHandle)
+}
+
+func updatePGSQLDatabaseFromV5(dbHandle *sql.DB) error {
+	return updatePGSQLDatabaseFrom5To6(dbHandle)
 }
 
 func updatePGSQLDatabaseFrom1To2(dbHandle *sql.DB) error {
@@ -270,3 +281,10 @@ func updatePGSQLDatabaseFrom3To4(dbHandle *sql.DB) error {
 func updatePGSQLDatabaseFrom4To5(dbHandle *sql.DB) error {
 	return sqlCommonUpdateDatabaseFrom4To5(dbHandle)
 }
+
+func updatePGSQLDatabaseFrom5To6(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 5 -> 6")
+	providerLog(logger.LevelInfo, "updating database version: 5 -> 6")
+	sql := strings.Replace(pgsqlV6SQL, "{{users}}", sqlTableUsers, 1)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 6)
+}

+ 11 - 5
dataprovider/sqlcommon.go

@@ -14,7 +14,7 @@ import (
 )
 
 const (
-	sqlDatabaseVersion     = 5
+	sqlDatabaseVersion     = 6
 	initialDBVersionSQL    = "INSERT INTO {{schema_version}} (version) VALUES (1);"
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
@@ -218,7 +218,7 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
 	}
 	_, err = stmt.ExecContext(ctx, user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
 		user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters),
-		string(fsConfig))
+		string(fsConfig), user.AdditionalInfo)
 	if err != nil {
 		sqlCommonRollbackTransaction(tx)
 		return err
@@ -272,7 +272,7 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
 	}
 	_, err = stmt.ExecContext(ctx, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
 		user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
-		string(filters), string(fsConfig), user.ID)
+		string(filters), string(fsConfig), user.AdditionalInfo, user.ID)
 	if err != nil {
 		sqlCommonRollbackTransaction(tx)
 		return err
@@ -391,15 +391,18 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
 	var publicKey sql.NullString
 	var filters sql.NullString
 	var fsConfig sql.NullString
+	var additionalInfo sql.NullString
 	var err error
 	if row != nil {
 		err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
 			&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
-			&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig)
+			&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
+			&additionalInfo)
 	} else {
 		err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
 			&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
-			&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig)
+			&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
+			&additionalInfo)
 	}
 	if err != nil {
 		if err == sql.ErrNoRows {
@@ -440,6 +443,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
 			user.FsConfig = fs
 		}
 	}
+	if additionalInfo.Valid {
+		user.AdditionalInfo = additionalInfo.String
+	}
 	return user, err
 }
 

+ 19 - 1
dataprovider/sqlite.go

@@ -63,6 +63,7 @@ ALTER TABLE "new__users" RENAME TO "{{users}}";
 CREATE INDEX "folders_mapping_folder_id_idx" ON "{{folders_mapping}}" ("folder_id");
 CREATE INDEX "folders_mapping_user_id_idx" ON "{{folders_mapping}}" ("user_id");
 `
+	sqliteV6SQL = `ALTER TABLE "{{users}}" ADD COLUMN "additional_info" text NULL;`
 )
 
 // SQLiteProvider auth provider for SQLite database
@@ -239,6 +240,8 @@ func (p SQLiteProvider) migrateDatabase() error {
 		return updateSQLiteDatabaseFromV3(p.dbHandle)
 	case 4:
 		return updateSQLiteDatabaseFromV4(p.dbHandle)
+	case 5:
+		return updateSQLiteDatabaseFromV5(p.dbHandle)
 	default:
 		return fmt.Errorf("Database version not handled: %v", dbVersion.Version)
 	}
@@ -269,7 +272,15 @@ func updateSQLiteDatabaseFromV3(dbHandle *sql.DB) error {
 }
 
 func updateSQLiteDatabaseFromV4(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom4To5(dbHandle)
+	err := updateSQLiteDatabaseFrom4To5(dbHandle)
+	if err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV5(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV5(dbHandle *sql.DB) error {
+	return updateSQLiteDatabaseFrom5To6(dbHandle)
 }
 
 func updateSQLiteDatabaseFrom1To2(dbHandle *sql.DB) error {
@@ -293,3 +304,10 @@ func updateSQLiteDatabaseFrom3To4(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFrom4To5(dbHandle *sql.DB) error {
 	return sqlCommonUpdateDatabaseFrom4To5(dbHandle)
 }
+
+func updateSQLiteDatabaseFrom5To6(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 5 -> 6")
+	providerLog(logger.LevelInfo, "updating database version: 5 -> 6")
+	sql := strings.Replace(sqliteV6SQL, "{{users}}", sqlTableUsers, 1)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 6)
+}

+ 8 - 7
dataprovider/sqlqueries.go

@@ -10,7 +10,7 @@ import (
 
 const (
 	selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
-		"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem"
+		"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem,additional_info"
 	selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update"
 )
 
@@ -72,19 +72,20 @@ func getQuotaQuery() string {
 func getAddUserQuery() string {
 	return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
 		used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
-		filesystem)
-		VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1],
+		filesystem,additional_info)
+		VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v)`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1],
 		sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
 		sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
-		sqlPlaceholders[14], sqlPlaceholders[15])
+		sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16])
 }
 
 func getUpdateUserQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
-		quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v
-		WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
+		quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v,
+		additional_info=%v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
 		sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
-		sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15])
+		sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15],
+		sqlPlaceholders[16])
 }
 
 func getDeleteUserQuery() string {

+ 3 - 0
dataprovider/user.go

@@ -214,6 +214,8 @@ type User struct {
 	Filters UserFilters `json:"filters"`
 	// Filesystem configuration details
 	FsConfig Filesystem `json:"filesystem"`
+	// free form text field for external systems
+	AdditionalInfo string `json:"additional_info,omitempty"`
 }
 
 // GetFilesystem returns the filesystem for this user
@@ -849,6 +851,7 @@ func (u *User) getACopy() User {
 		LastLogin:         u.LastLogin,
 		Filters:           filters,
 		FsConfig:          fsConfig,
+		AdditionalInfo:    u.AdditionalInfo,
 	}
 }
 

+ 1 - 0
docs/account.md

@@ -73,6 +73,7 @@ For each account, the following properties can be configured:
 - `az_upload_concurrency`,  how many parts are uploaded in parallel. Zero means the default (2)
 - `az_key_prefix`,  allows to restrict access to the folder identified by this prefix and its contents
 - `az_use_emulator`, boolean
+- `additional_info`, string. Free text field
 
 These properties are stored inside the data provider.
 

+ 2 - 1
examples/rest-api-cli/README.md

@@ -44,13 +44,14 @@ Let's see a sample usage for each REST API.
 Command:
 
 ```console
-python sftpgo_api_cli add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --s3-upload-part-size 10 --s3-upload-concurrency 4 --denied-login-methods "password" "keyboard-interactive" --allowed-patterns "/dir1::*.jpg,*.png" "/dir2::*.rar,*.png" --denied-patterns "/dir3::*.zip,*.rar" --denied-protocols DAV FTP
+python sftpgo_api_cli add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --s3-upload-part-size 10 --s3-upload-concurrency 4 --denied-login-methods "password" "keyboard-interactive" --allowed-patterns "/dir1::*.jpg,*.png" "/dir2::*.rar,*.png" --denied-patterns "/dir3::*.zip,*.rar" --denied-protocols DAV FTP --additional-info "sample info"
 ```
 
 Output:
 
 ```json
 {
+  "additional_info": "sample info",
   "download_bandwidth": 60,
   "expiration_date": 1546297200000,
   "filesystem": {

+ 9 - 8
examples/rest-api-cli/sftpgo_api_cli

@@ -84,11 +84,11 @@ class SFTPGoApiRequests:
 					denied_patterns=[], allowed_patterns=[], s3_upload_part_size=0, s3_upload_concurrency=0,
 					max_upload_file_size=0, denied_protocols=[], az_container='', az_account_name='', az_account_key='',
 					az_sas_url='', az_endpoint='', az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix='',
-					az_use_emulator=False, az_access_tier=''):
+					az_use_emulator=False, az_access_tier='', additional_info=''):
 		user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid,
 			'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files,
 			'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
-			'status':status, 'expiration_date':expiration_date}
+			'status':status, 'expiration_date':expiration_date, 'additional_info':additional_info}
 		if password is not None:
 			user.update({'password':password})
 		if public_keys:
@@ -285,7 +285,7 @@ class SFTPGoApiRequests:
 			denied_login_methods=[], virtual_folders=[], denied_patterns=[], allowed_patterns=[],
 			s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, denied_protocols=[], az_container="",
 			az_account_name='', az_account_key='', az_sas_url='', az_endpoint='', az_upload_part_size=0,
-			az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False, az_access_tier=''):
+			az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False, az_access_tier='', additional_info=''):
 		u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
 			quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
 			status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
@@ -293,7 +293,7 @@ class SFTPGoApiRequests:
 			gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_patterns,
 			allowed_patterns, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols,
 			az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size,
-			az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier)
+			az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier, additional_info)
 		r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
 		self.printResponse(r)
 
@@ -306,7 +306,7 @@ class SFTPGoApiRequests:
 				allowed_patterns=[], s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0,
 				denied_protocols=[], disconnect=0, az_container='', az_account_name='', az_account_key='', az_sas_url='',
 				az_endpoint='', az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False,
-				az_access_tier=''):
+				az_access_tier='', additional_info=''):
 		u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
 			quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
 			status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
@@ -314,7 +314,7 @@ class SFTPGoApiRequests:
 			gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_patterns,
 			allowed_patterns, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols,
 			az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size,
-			az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier)
+			az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier, additional_info)
 		r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), params={'disconnect':disconnect},
 						json=u, auth=self.auth, verify=self.verify)
 		self.printResponse(r)
@@ -608,6 +608,7 @@ def addCommonUserArguments(parser):
 							help='User\'s status. 1 enabled, 0 disabled. Default: %(default)s')
 	parser.add_argument('--max-upload-file-size', type=int, default=0,
 					help='Maximum allowed size, as bytes, for a single file upload, 0 means unlimited. Default: %(default)s')
+	parser.add_argument('--additional-info', type=str, default='', help='Free form text field. Default: %(default)s')
 	parser.add_argument('-E', '--expiration-date', type=validDate, default='',
 					help='Expiration date as YYYY-MM-DD, empty string means no expiration. Default: %(default)s')
 	parser.add_argument('-Y', '--allowed-ip', type=str, nargs='+', default=[],
@@ -815,7 +816,7 @@ if __name__ == '__main__':
 				args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols,
 				args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint,
 				args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator,
-				args.az_access_tier)
+				args.az_access_tier, args.additional_info)
 	elif args.command == 'update-user':
 		api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
 					args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
@@ -828,7 +829,7 @@ if __name__ == '__main__':
 					args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, args.disconnect,
 					args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint,
 					args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator,
-					args.az_access_tier)
+					args.az_access_tier, args.additional_info)
 	elif args.command == 'delete-user':
 		api.deleteUser(args.id)
 	elif args.command == 'get-users':

+ 6 - 0
httpd/api_utils.go

@@ -704,6 +704,9 @@ func compareAzBlobConfig(expected *dataprovider.User, actual *dataprovider.User)
 	if expected.FsConfig.AzBlobConfig.UseEmulator != actual.FsConfig.AzBlobConfig.UseEmulator {
 		return errors.New("Azure Blob use emulator mismatch")
 	}
+	if expected.FsConfig.AzBlobConfig.AccessTier != actual.FsConfig.AzBlobConfig.AccessTier {
+		return errors.New("Azure Blob access tier mismatch")
+	}
 	return nil
 }
 
@@ -861,6 +864,9 @@ func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.U
 	if expected.ExpirationDate != actual.ExpirationDate {
 		return errors.New("ExpirationDate mismatch")
 	}
+	if expected.AdditionalInfo != actual.AdditionalInfo {
+		return errors.New("AdditionalInfo mismatch")
+	}
 	return nil
 }
 

+ 7 - 1
httpd/httpd_test.go

@@ -243,7 +243,7 @@ func TestBasicUserHandling(t *testing.T) {
 	user.UploadBandwidth = 128
 	user.DownloadBandwidth = 64
 	user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now())
-
+	user.AdditionalInfo = "some free text"
 	originalUser := user
 	user, _, err = httpd.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
@@ -2715,6 +2715,7 @@ func TestWebUserAddMock(t *testing.T) {
 	user.UploadBandwidth = 32
 	user.DownloadBandwidth = 64
 	user.UID = 1000
+	user.AdditionalInfo = "info"
 	mappedDir := filepath.Join(os.TempDir(), "mapped")
 	form := make(url.Values)
 	form.Set("username", user.Username)
@@ -2729,6 +2730,7 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("denied_extensions", "/dir2::.webp,.webp\n/dir2::.tiff\n/dir1::.zip")
 	form.Set("allowed_patterns", "/dir2::*.jpg,*.png\n/dir1::*.png")
 	form.Set("denied_patterns", "/dir1::*.zip\n/dir3::*.rar\n/dir2::*.mkv")
+	form.Set("additional_info", user.AdditionalInfo)
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	// test invalid url escape
 	req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
@@ -2848,6 +2850,7 @@ func TestWebUserAddMock(t *testing.T) {
 	assert.Equal(t, user.UploadBandwidth, newUser.UploadBandwidth)
 	assert.Equal(t, user.DownloadBandwidth, newUser.DownloadBandwidth)
 	assert.Equal(t, int64(1000), newUser.Filters.MaxUploadFileSize)
+	assert.Equal(t, user.AdditionalInfo, newUser.AdditionalInfo)
 	assert.True(t, utils.IsStringInSlice(testPubKey, newUser.PublicKeys))
 	if val, ok := newUser.Permissions["/subdir"]; ok {
 		assert.True(t, utils.IsStringInSlice(dataprovider.PermListItems, val))
@@ -2926,6 +2929,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	user.QuotaFiles = 2
 	user.QuotaSize = 3
 	user.GID = 1000
+	user.AdditionalInfo = "new additional info"
 	form := make(url.Values)
 	form.Set("username", user.Username)
 	form.Set("home_dir", user.HomeDir)
@@ -2947,6 +2951,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	form.Set("denied_protocols", common.ProtocolFTP)
 	form.Set("max_upload_file_size", "100")
 	form.Set("disconnect", "1")
+	form.Set("additional_info", user.AdditionalInfo)
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req.Header.Set("Content-Type", contentType)
@@ -2966,6 +2971,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	assert.Equal(t, user.QuotaSize, updateUser.QuotaSize)
 	assert.Equal(t, user.UID, updateUser.UID)
 	assert.Equal(t, user.GID, updateUser.GID)
+	assert.Equal(t, user.AdditionalInfo, updateUser.AdditionalInfo)
 	assert.Equal(t, int64(100), updateUser.Filters.MaxUploadFileSize)
 
 	if val, ok := updateUser.Permissions["/otherdir"]; ok {

+ 8 - 0
httpd/internal_test.go

@@ -311,6 +311,10 @@ func TestCompareUserFields(t *testing.T) {
 	expected.ExpirationDate = 123
 	err = compareEqualsUserFields(expected, actual)
 	assert.Error(t, err)
+	expected.ExpirationDate = 0
+	expected.AdditionalInfo = "info"
+	err = compareEqualsUserFields(expected, actual)
+	assert.Error(t, err)
 }
 
 func TestCompareUserFsConfig(t *testing.T) {
@@ -443,6 +447,10 @@ func TestCompareUserAzureConfig(t *testing.T) {
 	err = compareUserFsConfig(expected, actual)
 	assert.Error(t, err)
 	expected.FsConfig.AzBlobConfig.UseEmulator = false
+	expected.FsConfig.AzBlobConfig.AccessTier = "Hot"
+	err = compareUserFsConfig(expected, actual)
+	assert.Error(t, err)
+	expected.FsConfig.AzBlobConfig.AccessTier = ""
 }
 
 func TestGCSWebInvalidFormFile(t *testing.T) {

+ 4 - 1
httpd/schema/openapi.yaml

@@ -2,7 +2,7 @@ openapi: 3.0.3
 info:
   title: SFTPGo
   description: 'SFTPGo REST API'
-  version: 2.1.0
+  version: 2.1.1
 
 servers:
   - url: /api/v1
@@ -1239,6 +1239,9 @@ components:
           $ref: '#/components/schemas/UserFilters'
         filesystem:
           $ref: '#/components/schemas/FilesystemConfig'
+        additional_info:
+          type: string
+          description: Free form text field for external systems
     Transfer:
       type: object
       properties:

+ 1 - 0
httpd/web.go

@@ -589,6 +589,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 		ExpirationDate:    expirationDateMillis,
 		Filters:           getFiltersFromUserPostFields(r),
 		FsConfig:          fsConfig,
+		AdditionalInfo:    r.Form.Get("additional_info"),
 	}
 	maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64)
 	user.Filters.MaxUploadFileSize = maxFileSize

+ 11 - 0
templates/user.html

@@ -517,6 +517,17 @@
         </div>
     </div>
 
+    <div class="form-group row">
+        <label for="idAdditionalInfo" class="col-sm-2 col-form-label">Additional info</label>
+        <div class="col-sm-10">
+            <textarea class="form-control" id="idAdditionalInfo" name="additional_info" rows="3"
+                aria-describedby="additionalInfoHelpBlock">{{.User.AdditionalInfo}}</textarea>
+            <small id="additionalInfoHelpBlock" class="form-text text-muted">
+                Free form text field
+            </small>
+        </div>
+    </div>
+
     {{if not .IsAdd}}
     <div class="form-group">
         <div class="form-check">