diff --git a/aurorastation.dme b/aurorastation.dme index 1692e089496..4bbd1183f80 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -411,6 +411,7 @@ #include "code\controllers\subsystems\evacuation\evacuation_shuttle.dm" #include "code\controllers\subsystems\initialization\atlas.dm" #include "code\controllers\subsystems\initialization\atoms.dm" +#include "code\controllers\subsystems\initialization\auth.dm" #include "code\controllers\subsystems\initialization\codex.dm" #include "code\controllers\subsystems\initialization\fabrication.dm" #include "code\controllers\subsystems\initialization\holomap.dm" @@ -2434,8 +2435,8 @@ #include "code\modules\holodeck\HolodeckPrograms.dm" #include "code\modules\holomap\poi.dm" #include "code\modules\holomap\portable_map_reader.dm" +#include "code\modules\http\authentik_api.dm" #include "code\modules\http\forum_api.dm" -#include "code\modules\http\forumuser_api.dm" #include "code\modules\http\http.dm" #include "code\modules\hydroponics\grown.dm" #include "code\modules\hydroponics\grown_inedible.dm" diff --git a/code/__DEFINES/admin.dm b/code/__DEFINES/admin.dm index 35c4cd5cf34..593ebf57df0 100644 --- a/code/__DEFINES/admin.dm +++ b/code/__DEFINES/admin.dm @@ -27,14 +27,17 @@ #define R_ADMIN BITFLAG(1) #define R_BAN BITFLAG(2) #define R_FUN BITFLAG(3) + #define R_SERVER BITFLAG(4) #define R_DEBUG BITFLAG(5) #define R_POSSESS BITFLAG(6) #define R_PERMISSIONS BITFLAG(7) + #define R_STEALTH BITFLAG(8) #define R_REJUVENATE BITFLAG(9) #define R_VAREDIT BITFLAG(10) #define R_SOUNDS BITFLAG(11) + #define R_SPAWN BITFLAG(12) #define R_MOD BITFLAG(13) #define R_DEV BITFLAG(14) diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 96aaaa664ba..0e3c1aef02e 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -215,6 +215,7 @@ #define INIT_ORDER_PROFILER 101 #define INIT_ORDER_GARBAGE 99 #define INIT_ORDER_DBCORE 95 +#define INIT_ORDER_AUTH 94 //Admin permissions should be loaded early on #define INIT_ORDER_SOUNDS 83 #define INIT_ORDER_DISCORD 78 #define INIT_ORDER_JOBS 65 // Must init before atoms, to set up properly the dynamic job lists. diff --git a/code/controllers/configuration.dm b/code/controllers/configuration.dm index 1d6f86747d6..0ff51c22788 100644 --- a/code/controllers/configuration.dm +++ b/code/controllers/configuration.dm @@ -460,9 +460,9 @@ GLOBAL_LIST_EMPTY(gamemode_cache) // global.forum_api_key - see modules/http/forum_api.dm var/news_use_forum_api = FALSE - var/forumuser_api_url - var/use_forumuser_api = FALSE - // global.forumuser_api_key - see modules/http/forumuser_api.dm + var/authentik_api_url + var/authentik_api_key + var/use_authentik_api = FALSE var/profiler_is_enabled = FALSE var/profiler_restart_period = 120 SECONDS @@ -1072,12 +1072,12 @@ GENERAL_PROTECT_DATUM(/datum/configuration) if ("profiler_timeout_threshold") profiler_timeout_threshold = text2num(value) - if ("forumuser_api_url") - forumuser_api_url = value - if ("use_forumuser_api") - use_forumuser_api = TRUE - if ("forumuser_api_key") - global.forumuser_api_key = value + if ("authentik_api_url") + GLOB.config.authentik_api_url = value + if ("use_authentik_api") + GLOB.config.use_authentik_api = TRUE + if ("authentik_api_key") + GLOB.config.authentik_api_key = value if ("external_rsc_urls") external_rsc_urls = splittext(value, ",") diff --git a/code/controllers/subsystems/initialization/auth.dm b/code/controllers/subsystems/initialization/auth.dm new file mode 100644 index 00000000000..a4e59e92647 --- /dev/null +++ b/code/controllers/subsystems/initialization/auth.dm @@ -0,0 +1,356 @@ +// This subsystem loads later in the init process. Not last, but after most major things are done. + +SUBSYSTEM_DEF(auth) + name = "Auth" + init_order = INIT_ORDER_AUTH + flags = SS_NO_FIRE + + + var/list/admin_ranks = list() /// list of all ranks with associated rights used by the legacy system + var/list/datum/authentik_group/authentik_groups = list() /// list of all groups with associated rights used by the new system + +/datum/controller/subsystem/auth/can_vv_get(var_name) + if(var_name == NAMEOF(src, admin_ranks)) + return TRUE // we allow viewing the admin ranks + if(var_name == NAMEOF(src, authentik_groups)) + return TRUE // we allow viewing the authentik groups + return ..() + +/datum/controller/subsystem/auth/vv_edit_var(var_name, var_value) + if(var_name == NAMEOF(src, admin_ranks)) + return FALSE //but we dont allow editing them + if(var_name == NAMEOF(src, authentik_groups)) + return FALSE //but we dont allow editing them + return ..() + +/datum/controller/subsystem/auth/Initialize(timeofday) + load_admin_ranks() + load_admins() + + if (GLOB.config.use_authentik_api) + update_admins_from_authentik(TRUE) + + return SS_INIT_SUCCESS + +/** + * Loads the admin ranks from the config file. + * In the legacy system this is used to determine what rights an admin has. + * In the new system authentik groups are used to determine what rights an admin has. + * + * @param rank_file The file to load the admin ranks from. Defaults to "config/admin_ranks.json" + */ +/datum/controller/subsystem/auth/proc/load_admin_ranks(rank_file="config/admin_ranks.json") + admin_ranks.Cut() + + if(!(rustg_file_exists(rank_file) == "true")) + log_config("The file [rank_file] does not exist, unable to load the admin ranks.") + return + + var/list/data = json_decode(file2text(rank_file)) + + for (var/list/group in data) + var/datum/admin_rank/rank = new(group) + admin_ranks[rank.rank_name] = rank + +/** + * Clears and Loads the admins. + * If the legacy system is enabled, it loads the admins from the admins.txt file + * If the legacy system is disabled, it loads the admins from the database, as they currently exist.load_admins() + * Does not automatically update the admins from the authentik API. This needs to be enabled separately using the update_admins_from_authentik() proc + */ +/datum/controller/subsystem/auth/proc/load_admins() + clear_admins() + + if(GLOB.config.admin_legacy_system) + load_admins_from_legacy_system() + + else + load_admins_from_database() + +#ifdef TESTING + var/msg = "Admins Built:\n" + for(var/ckey in admin_datums) + var/rank + var/datum/admins/D = admin_datums[ckey] + if(D) rank = D.rank + msg += "\t[ckey] - [rank]\n" + testing(msg) +#endif + +/datum/controller/subsystem/auth/proc/load_admins_from_legacy_system() + PRIVATE_PROC(TRUE) + //load text from file + var/list/Lines = file2list("config/admins.txt") + + //process each line seperately + for(var/line in Lines) + if(!length(line)) + continue + if(copytext(line,1,2) == "#") + continue + + //Split the line at every "-" + var/list/List = text2list(line, "-") + if(List.len != 2) + continue + + //ckey is before the first "-" + var/ckey = ckey(List[1]) + if(!ckey) + continue + + //rank follows the first "-" + var/rank = trim(List[2]) + + //load permissions associated with this rank + var/datum/admin_rank/rank_object = admin_ranks[rank] + + if (!rank_object) + log_world("ERROR: Unrecognized rank in admins.txt: \"[rank]\"") + continue + + //create the admin datum and store it for later use + var/datum/admins/D = new /datum/admins(rank, rank_object?.rights || 0, ckey) + + //find the client for a ckey if they are connected and associate them with the new admin datum + D.associate(GLOB.directory[ckey]) + + LOG_DEBUG("AdminRanks: Updated Admins from Legacy System") + +/datum/controller/subsystem/auth/proc/load_admins_from_database() + PRIVATE_PROC(TRUE) + //The current admin system uses SQL + if(!SSdbcore.Connect()) + log_world("ERROR: AdminRanks: Failed to connect to database in load_admins(). Reverting to legacy system.") + log_misc("AdminRanks: Failed to connect to database in load_admins(). Reverting to legacy system.") + GLOB.config.admin_legacy_system = 1 + load_admins() + return + + var/datum/db_query/query = SSdbcore.NewQuery("SELECT ckey, `rank`, flags FROM ss13_admins") + query.Execute(FALSE) + + while(query.NextRow()) + var/ckey = query.item[1] + var/rank = query.item[2] + var/rights = query.item[3] + if(istext(rights)) + rights = text2num(rights) + + var/datum/admins/D = new /datum/admins(rank, rights, ckey) + //find the client for a ckey if they are connected and associate them with the new admin datum + D.associate(GLOB.directory[ckey]) + + if(!admin_datums) + log_world("ERROR: AdminRanks: The database query in load_admins() resulted in no admins being added to the list. Reverting to legacy system.") + log_misc("AdminRanks: The database query in load_admins() resulted in no admins being added to the list. Reverting to legacy system.") + GLOB.config.admin_legacy_system = 1 + load_admins_from_legacy_system() + return +/** + * Clears the admins from the game. + */ +/datum/controller/subsystem/auth/proc/clear_admins() + PRIVATE_PROC(TRUE) + //clear the datums references + admin_datums.Cut() + for(var/s in GLOB.staff) + var/client/C = s + C.remove_admin_verbs() + C.holder = null + GLOB.staff.Cut() + + // Clears admins from the world config. + for (var/A in world.GetConfig("admin")) + world.SetConfig("APP/admin", A, null) + +/** + * Updates the admins from the Authentik API. + * + * @param reload_once_done Whether to reload the admins once the update is done. + * @return Whether the update was successful. + */ +/datum/controller/subsystem/auth/proc/update_admins_from_authentik(reload_once_done=FALSE) + if(!SSdbcore.Connect()) + log_and_message_admins("AdminRanks: Failed to connect to database in update_admins_from_authentik(). Carrying on with old staff lists.") + return FALSE + + // Step 1: Fetch all groups from Authentik with pagination + var/list/datum/authentik_group/all_groups = list() + var/current_page = 1 + var/has_next = TRUE + + while (has_next) + var/datum/http_request/authentik_api/groups_req = new() + groups_req.prepare_groups_query(current_page) + groups_req.begin_async() + + UNTIL(groups_req.is_complete()) + + var/datum/http_response/groups_resp = groups_req.into_response() + + if (groups_resp.errored) + log_and_message_admins("AdminRanks: Loading groups from Authentik API FAILED. Please alert web-service maintainers!") + crash_with("Groups request errored with: [groups_resp.error]") + return FALSE + + if (groups_resp.status_code != 200) + log_and_message_admins("AdminRanks: Loading groups from Authentik API FAILED. Please alert web-service maintainers!") + crash_with("Groups request failed with status code: [groups_resp.status_code] body: [json_encode(groups_resp.body)]") + return FALSE + + // Parse groups from this page + var/list/results = groups_resp.body["results"] + for (var/group_data in results) + var/datum/authentik_group/group = new(group_data) + all_groups += group + + // Check for next page + var/list/pagination = groups_resp.body["pagination"] + if (pagination && pagination["next"]) + current_page = pagination["next"] + else + has_next = FALSE + + // Step 2: Filter groups by gameserver.sync + authentik_groups = list() + var/list/group_ids = list() + + for (var/datum/authentik_group/group in all_groups) + if (group.sync_enabled) + authentik_groups += group + group_ids += group.group_id + + if (!length(authentik_groups)) + log_and_message_admins("AdminRanks: No groups with gameserver.sync enabled found in Authentik.") + return FALSE + + // Step 3: Fetch users for sync-enabled groups with pagination + var/list/datum/authentik_user/all_users = list() + current_page = 1 + has_next = TRUE + + while (has_next) + var/datum/http_request/authentik_api/users_req = new() + users_req.prepare_users_query(group_ids, current_page) + users_req.begin_async() + + UNTIL(users_req.is_complete()) + + var/datum/http_response/users_resp = users_req.into_response() + + if (users_resp.errored) + crash_with("Users request errored with: [users_resp.error]") + log_and_message_admins("AdminRanks: Loading users from Authentik API FAILED. Please alert web-service maintainers immediately!") + return FALSE + + if (users_resp.status_code != 200) + log_and_message_admins("AdminRanks: Loading users from Authentik API FAILED. Please alert web-service maintainers!") + crash_with("Users request failed with status code: [users_resp.status_code] body: [json_encode(users_resp.body)]") + return FALSE + + + // Parse users from this page + var/list/user_results = users_resp.body["results"] + for (var/user_data in user_results) + var/datum/authentik_user/user = new(user_data) + all_users += user + + // Check for next page + var/list/pagination = users_resp.body["pagination"] + if (pagination && pagination["next"]) + current_page = pagination["next"] + else + has_next = FALSE + + // Step 4: Process users and prepare them for the MassInsert Query + var/list/admins_to_push = list() + + for (var/datum/authentik_user/user in all_users) + // Skip users without ckey + if (isnull(user.ckey) || !length(user.ckey)) + LOG_DEBUG("AdminRanks: [user.username] does not have a ckey linked - Ignoring") + continue + + // Determine primary group + user.determine_primary_group() + + var/list/admin = list() + admin["status"] = 1 + admin["ckey"] = ckey(user.ckey) + + // Aggregate rights from all groups + admin["flags"] = user.aggregate_rights() + + // Get primary rank name + var/primary_rank = user.primary_group_name + if (!primary_rank) + primary_rank = "Staff Member" + admin["rank"] = primary_rank + + admins_to_push[++admins_to_push.len] = admin + + // Step 5: Update database + var/datum/db_query/prep_query = SSdbcore.NewQuery("UPDATE ss13_admins SET status = 0") + prep_query.Execute(FALSE) //Needs to be executed sync, as we need to wait for it to finish before we can update the admins + qdel(prep_query) + + SSdbcore.MassInsert("ss13_admins", admins_to_push, duplicate_key=TRUE, ignore_errors=FALSE, warn=TRUE, async=FALSE) + + var/datum/db_query/del_query = SSdbcore.NewQuery("DELETE FROM ss13_admins WHERE status = 0") + del_query.Execute(FALSE) + qdel(del_query) + + if (reload_once_done) + load_admins_from_database() + + LOG_DEBUG("AdminRanks: Updated Admins from Authentik API") + + return TRUE + +/** + * Converts a list of auth strings into a bitmask of rights. + * + * @param auths The list of auth strings to convert. + * @return The bitmask of rights. + */ +/datum/controller/subsystem/auth/proc/auths_to_rights(list/auths) + SHOULD_BE_PURE(TRUE) + . = 0 + + for (var/auth in auths) + switch (lowertext(auth)) + if ("r_buildmode","r_build") + . |= R_BUILDMODE + if ("r_admin") + . |= R_ADMIN + if ("r_ban") + . |= R_BAN + if ("r_fun") + . |= R_FUN + if ("r_server") + . |= R_SERVER + if ("r_debug") + . |= R_DEBUG + if ("r_permissions","r_rights") + . |= R_PERMISSIONS + if ("r_possess") + . |= R_POSSESS + if ("r_stealth") + . |= R_STEALTH + if ("r_rejuv","r_rejuvenate") + . |= R_REJUVENATE + if ("r_varedit") + . |= R_VAREDIT + if ("r_sound","r_sounds") + . |= R_SOUNDS + if ("r_spawn","r_create") + . |= R_SPAWN + if ("r_moderator", "r_mod") + . |= R_MOD + if ("r_developer", "r_dev") + . |= R_DEV + if ("r_cciaa") + . |= R_CCIAA + if ("r_everything","r_host","r_all") + . |= (R_BUILDMODE | R_ADMIN | R_BAN | R_FUN | R_SERVER | R_DEBUG | R_PERMISSIONS | R_POSSESS | R_STEALTH | R_REJUVENATE | R_VAREDIT | R_SOUNDS | R_SPAWN | R_MOD | R_CCIAA | R_DEV) diff --git a/code/controllers/subsystems/initialization/misc_late.dm b/code/controllers/subsystems/initialization/misc_late.dm index deaf4f98b66..21dc94b93eb 100644 --- a/code/controllers/subsystems/initialization/misc_late.dm +++ b/code/controllers/subsystems/initialization/misc_late.dm @@ -34,9 +34,6 @@ SUBSYSTEM_DEF(misc_late) thing.do_late_fire() LAZYREMOVE(late_misc_firers, thing) - if (GLOB.config.use_forumuser_api) - update_admins_from_api(TRUE) - // Load outfits here so that the verb isn't laggy as balls. for(var/outfit_type in subtypesof(/obj/outfit)) var/obj/outfit/new_outfit = new outfit_type() diff --git a/code/modules/admin/admin_ranks.dm b/code/modules/admin/admin_ranks.dm index 9ae396a8323..f1a184bb985 100644 --- a/code/modules/admin/admin_ranks.dm +++ b/code/modules/admin/admin_ranks.dm @@ -1,6 +1,3 @@ -var/list/admin_ranks = list() //list of all ranks with associated rights -var/list/forum_groupids_to_ranks = list() - /datum/admin_rank var/rank_name var/group_id @@ -9,225 +6,4 @@ var/list/forum_groupids_to_ranks = list() /datum/admin_rank/New(raw_data) group_id = raw_data["role_id"] rank_name = raw_data["name"] - rights = auths_to_rights(raw_data["auths"]) - -/datum/admin_rank/proc/auths_to_rights(list/auths) - . = 0 - - for (var/auth in auths) - switch (lowertext(auth)) - if ("r_buildmode","r_build") - . |= R_BUILDMODE - if ("r_admin") - . |= R_ADMIN - if ("r_ban") - . |= R_BAN - if ("r_fun") - . |= R_FUN - if ("r_server") - . |= R_SERVER - if ("r_debug") - . |= R_DEBUG - if ("r_permissions","r_rights") - . |= R_PERMISSIONS - if ("r_possess") - . |= R_POSSESS - if ("r_stealth") - . |= R_STEALTH - if ("r_rejuv","r_rejuvenate") - . |= R_REJUVENATE - if ("r_varedit") - . |= R_VAREDIT - if ("r_sound","r_sounds") - . |= R_SOUNDS - if ("r_spawn","r_create") - . |= R_SPAWN - if ("r_moderator") - . |= R_MOD - if ("r_developer") - . |= R_DEV - if ("r_cciaa") - . |= R_CCIAA - if ("r_everything","r_host","r_all") - . |= (R_BUILDMODE | R_ADMIN | R_BAN | R_FUN | R_SERVER | R_DEBUG | R_PERMISSIONS | R_POSSESS | R_STEALTH | R_REJUVENATE | R_VAREDIT | R_SOUNDS | R_SPAWN | R_MOD | R_CCIAA | R_DEV) - else - crash_with("Unknown rank in file: [auth]") - -/proc/load_admin_ranks(rank_file="config/admin_ranks.json") - admin_ranks.Cut() - forum_groupids_to_ranks.Cut() - - if(!(rustg_file_exists(rank_file) == "true")) - log_config("The file [rank_file] does not exist, unable to load the admin ranks.") - return - - var/list/data = json_decode(file2text(rank_file)) - - for (var/list/group in data) - var/datum/admin_rank/rank = new(group) - admin_ranks[rank.rank_name] = rank - forum_groupids_to_ranks["[rank.group_id]"] = rank - -/hook/startup/proc/loadAdmins() - load_admin_ranks() - load_admins() - return 1 - -/proc/load_admins() - clear_admins() - - if(GLOB.config.admin_legacy_system) - //load text from file - var/list/Lines = file2list("config/admins.txt") - - //process each line seperately - for(var/line in Lines) - if(!length(line)) - continue - if(copytext(line,1,2) == "#") - continue - - //Split the line at every "-" - var/list/List = text2list(line, "-") - if(List.len != 2) - continue - - //ckey is before the first "-" - var/ckey = ckey(List[1]) - if(!ckey) - continue - - //rank follows the first "-" - var/rank = trim(List[2]) - - //load permissions associated with this rank - var/datum/admin_rank/rank_object = admin_ranks[rank] - - if (!rank_object) - log_world("ERROR: Unrecognized rank in admins.txt: \"[rank]\"") - continue - - //create the admin datum and store it for later use - var/datum/admins/D = new /datum/admins(rank, rank_object?.rights || 0, ckey) - - //find the client for a ckey if they are connected and associate them with the new admin datum - D.associate(GLOB.directory[ckey]) - - LOG_DEBUG("AdminRanks: Updated Admins from Legacy System") - - else - //The current admin system uses SQL - if(!establish_db_connection(GLOB.dbcon)) - log_world("ERROR: AdminRanks: Failed to connect to database in load_admins(). Reverting to legacy system.") - log_misc("AdminRanks: Failed to connect to database in load_admins(). Reverting to legacy system.") - GLOB.config.admin_legacy_system = 1 - load_admins() - return - - var/DBQuery/query = GLOB.dbcon.NewQuery("SELECT ckey, `rank`, flags FROM ss13_admins;") - query.Execute() - while(query.NextRow()) - var/ckey = query.item[1] - var/rank = query.item[2] - var/rights = query.item[3] - if(istext(rights)) - rights = text2num(rights) - - var/datum/admins/D = new /datum/admins(rank, rights, ckey) - //find the client for a ckey if they are connected and associate them with the new admin datum - D.associate(GLOB.directory[ckey]) - - if(!admin_datums) - log_world("ERROR: AdminRanks: The database query in load_admins() resulted in no admins being added to the list. Reverting to legacy system.") - log_misc("AdminRanks: The database query in load_admins() resulted in no admins being added to the list. Reverting to legacy system.") - GLOB.config.admin_legacy_system = 1 - load_admins() - return - -#ifdef TESTING - var/msg = "Admins Built:\n" - for(var/ckey in admin_datums) - var/rank - var/datum/admins/D = admin_datums[ckey] - if(D) rank = D.rank - msg += "\t[ckey] - [rank]\n" - testing(msg) -#endif - -/proc/clear_admins() - //clear the datums references - admin_datums.Cut() - for(var/s in GLOB.staff) - var/client/C = s - C.remove_admin_verbs() - C.holder = null - GLOB.staff.Cut() - - // Clears admins from the world config. - for (var/A in world.GetConfig("admin")) - world.SetConfig("APP/admin", A, null) - -/proc/update_admins_from_api(reload_once_done=FALSE) - if (!establish_db_connection(GLOB.dbcon)) - log_and_message_admins("AdminRanks: Failed to connect to database in update_admins_from_api(). Carrying on with old staff lists.") - return FALSE - - var/list/admins_to_push = list() - - for (var/rank_name in admin_ranks) - var/datum/admin_rank/rank = admin_ranks[rank_name] - var/datum/http_request/forumuser_api/req = new() - - req.prepare_roles_query(rank.group_id) - req.begin_async() - - UNTIL(req.is_complete()) - - var/datum/http_response/resp = req.into_response() - - if (resp.errored) - crash_with("Role request errored for id [rank.group_id] with: [resp.error]") - log_and_message_admins("AdminRanks: Loading admins from forumuser API FAILED. Please alert web-service maintainers immediately!") - return FALSE - - for (var/datum/forum_user/user in resp.body) - admins_to_push += user - - var/DBQuery/prep_query = GLOB.dbcon.NewQuery("UPDATE ss13_admins SET status = 0") - prep_query.Execute() - - for (var/user in admins_to_push) - insert_user_to_admins_table(user) - - var/DBQuery/del_query = GLOB.dbcon.NewQuery("DELETE FROM ss13_admins WHERE status = 0") - del_query.Execute() - - if (reload_once_done) - load_admins() - - LOG_DEBUG("AdminRanks: Updated Admins from ForumUserAPI") - - return TRUE - -/proc/insert_user_to_admins_table(datum/forum_user/user) - if(isnull(user.ckey)) - LOG_DEBUG("AdminRanks: [user.forum_name] does not have a ckey linked - Ignoring") - return - if(user.psync_game_disabled) - LOG_DEBUG("AdminRanks: [user.forum_name] has permsync-game disabled - Ignoring") - return - var/rights = 0 - - for (var/group_id in (user.forum_secondary_groups + user.forum_primary_group)) - var/datum/admin_rank/r = forum_groupids_to_ranks["[group_id]"] - if (r) - rights |= r.rights - - var/primary_rank = "Administrator" - - if (forum_groupids_to_ranks["[user.forum_primary_group]"]) - var/datum/admin_rank/r = forum_groupids_to_ranks["[user.forum_primary_group]"] - primary_rank = r.rank_name - - var/DBQuery/query = GLOB.dbcon.NewQuery("INSERT INTO ss13_admins VALUES (:ckey:, :rank:, :flags:, 1) ON DUPLICATE KEY UPDATE rank = :rank:, flags = :flags:, status = 1") - query.Execute(list("ckey" = ckey(user.ckey), "rank" = primary_rank, "flags" = rights)) + rights = SSauth.auths_to_rights(raw_data["auths"]) diff --git a/code/modules/admin/permissionverbs/permissionedit.dm b/code/modules/admin/permissionverbs/permissionedit.dm index 53bbba63ce6..b3447a05fbd 100644 --- a/code/modules/admin/permissionverbs/permissionedit.dm +++ b/code/modules/admin/permissionverbs/permissionedit.dm @@ -37,7 +37,7 @@ admins += list(d) data["admins"] = admins - data["forumuserui_enabled"] = GLOB.config.use_forumuser_api + data["forumuserui_enabled"] = GLOB.config.use_authentik_api return data @@ -83,8 +83,8 @@ PRIVATE_PROC(TRUE) var/new_rank - if(admin_ranks.len) - new_rank = input("Please select a rank", "New rank", null, null) as null|anything in (admin_ranks|"*New Rank*") + if(SSauth.admin_ranks.len) + new_rank = input("Please select a rank", "New rank", null, null) as null|anything in (SSauth.admin_ranks|"*New Rank*") else new_rank = input("Please select a rank", "New rank", null, null) as null|anything in list("Game Master","Game Admin", "Trial Admin", "Admin Observer","*New Rank*") @@ -105,18 +105,18 @@ return - if(admin_ranks.len) - if(new_rank in admin_ranks) - new_admin_rank_datum = admin_ranks[new_rank] //we typed a rank which already exists, use its rights + if(SSauth.admin_ranks.len) + if(new_rank in SSauth.admin_ranks) + new_admin_rank_datum = SSauth.admin_ranks[new_rank] //we typed a rank which already exists, use its rights else - admin_ranks[new_rank] = 0 //add the new rank to admin_ranks + SSauth.admin_ranks[new_rank] = 0 //add the new rank to SSauth.admin_ranks else - new_admin_rank_datum = admin_ranks[new_rank] //we input an existing rank, use its rights + new_admin_rank_datum = SSauth.admin_ranks[new_rank] //we input an existing rank, use its rights if(D) D.disassociate() //remove adminverbs and unlink from client D.rank = new_rank //update the rank - D.rights = new_admin_rank_datum.rights //update the rights based on admin_ranks (default: 0) + D.rights = new_admin_rank_datum.rights //update the rights based on SSauth.admin_ranks (default: 0) else D = new /datum/admins(new_rank, rights, admin_ckey) diff --git a/code/modules/admin/verbs/diagnostics.dm b/code/modules/admin/verbs/diagnostics.dm index 95f0b855932..8958ca711e9 100644 --- a/code/modules/admin/verbs/diagnostics.dm +++ b/code/modules/admin/verbs/diagnostics.dm @@ -105,11 +105,11 @@ if(!check_rights(R_SERVER|R_DEV)) return - if (GLOB.config.use_forumuser_api) - update_admins_from_api(FALSE) + if (GLOB.config.use_authentik_api) + SSauth.update_admins_from_authentik(FALSE) log_and_message_admins("manually reloaded admins.") - load_admins() + SSauth.load_admins() feedback_add_details("admin_verb","RLDA") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! //todo: diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index 19e66600f5b..12334fafcaf 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -453,7 +453,7 @@ GLOBAL_LIST_INIT(localhost_addresses, list( to_chat_immediate(src, SPAN_ALERT("If the title screen is black, resources are still downloading. Please be patient until the title screen appears.")) - var/local_connection = (GLOB.config.auto_local_admin && !GLOB.config.use_forumuser_api && (isnull(address) || GLOB.localhost_addresses[address])) + var/local_connection = (GLOB.config.auto_local_admin && !GLOB.config.use_authentik_api && (isnull(address) || GLOB.localhost_addresses[address])) // Automatic admin rights for people connecting locally. // Concept stolen from /tg/ with deepest gratitude. // And ported from Nebula with love. diff --git a/code/modules/http/authentik_api.dm b/code/modules/http/authentik_api.dm new file mode 100644 index 00000000000..5601ed8c498 --- /dev/null +++ b/code/modules/http/authentik_api.dm @@ -0,0 +1,189 @@ +/** + * Authentik API + * + * This module handles the communication with the Authentik API. + * + * @see https://goauthentik.io/docs/api/ + */ + +/** + * Authentik Groups + * + * Groups are used to determine what rights a user has. + * The group with the highest priority is the primary group, which is used to determine the users's display rank. + * + * In Authentik the groups need to have the following attributes: + * - gameserver.sync: boolean - Whether the group should be synced to the gameserver. + * - gameserver.flags: list - The flags the group should have. + * - priority: number - The priority of the group. The group with the highest priority is the primary group. + */ +/datum/authentik_group + var/group_id // pk from API + var/group_name // name from API + var/sync_enabled // attributes.gameserver.sync + var/flags // attributes.gameserver.flags + var/priority // attributes.priority (default 0) + +/datum/authentik_group/New(data) + // Parse API response + group_id = data["pk"] + group_name = data["name"] + + // Parse attributes + var/list/attributes = data["attributes"] + if (attributes) + parse_attributes(attributes) + +/datum/authentik_group/proc/parse_attributes(list/attributes) + PRIVATE_PROC(TRUE) + // Extract gameserver settings + if (attributes["gameserver"]) + var/list/gameserver = attributes["gameserver"] + sync_enabled = gameserver["sync"] ? TRUE : FALSE + + if (gameserver["flags"]) + flags = gameserver["flags"] + else + flags = list() + else + sync_enabled = FALSE + flags = list() + + // Extract priority + if (attributes["priority"]) + priority = text2num("[attributes["priority"]]") + else + priority = 0 + +/** + * Authentik Users + * + * Need to have the following attributes: + * - accounts.byond.ckey: string - The ckey of the user. + * + * The groups of the users are mapped using the group_ids list, which contains the group IDs of the groups the user is in. + * The primary group is determined by the priority of the group. The group with the highest priority is the primary group. + * The permissions of all groups the user is in are combined to determine the users's permissions. + */ +/datum/authentik_user + var/user_id /// pk from API + var/username /// username from API + var/ckey /// attributes.accounts.byond.ckey + var/list/group_ids /// groups array from API + var/primary_group_id /// Determined by priority + var/primary_group_name /// Name of primary group + +/datum/authentik_user/New(data) + // Parse API response + user_id = data["pk"] + username = data["username"] + + // Parse attributes + var/list/attributes = data["attributes"] + if (attributes) + parse_attributes(attributes) + + // Parse groups + if (data["groups"]) + group_ids = data["groups"] + +/datum/authentik_user/proc/parse_attributes(list/attributes) + PRIVATE_PROC(TRUE) + // Extract BYOND ckey from nested attributes + if (attributes["accounts"]) + var/list/accounts = attributes["accounts"] + if (accounts["byond"]) + var/list/byond = accounts["byond"] + ckey = byond["ckey"] + +/** + * Determines the primary group of the user based on the priority of the groups. + * The group with the highest priority is the primary group. + * + * @returns string The name of the primary group. + */ +/datum/authentik_user/proc/determine_primary_group() + // Find group with highest priority + var/highest_priority = -1 + var/datum/authentik_group/primary = null + + for (var/datum/authentik_group/group in SSauth.authentik_groups) + if (group.group_id in group_ids) + if (group.priority > highest_priority) + highest_priority = group.priority + primary = group + + if (primary) + primary_group_id = primary.group_id + primary_group_name = primary.group_name + else + primary_group_name = "Administrator" // Default + return primary_group_name + +/** + * Aggregates the rights of all groups the user is in. + * + * @returns int The aggregated rights of the user. + */ +/datum/authentik_user/proc/aggregate_rights() + // Aggregate rights from all groups user belongs to + var/rights = 0 + + for (var/datum/authentik_group/group in SSauth.authentik_groups) + if (group.group_id in group_ids) + rights |= SSauth.auths_to_rights(group.flags) + + return rights + + +/datum/http_request/authentik_api + +/** + * Prepares a query to fetch groups from the Authentik API. + * Does not include users in the response. + * + * @param page The page number to fetch. + */ +/datum/http_request/authentik_api/proc/prepare_groups_query(page = 1) + var/url = "[GLOB.config.authentik_api_url]/core/groups/?page=[page]&include_users=false" + var/list/headers = list("Authorization" = "Bearer [GLOB.config.authentik_api_key]") + prepare(RUSTG_HTTP_METHOD_GET, url, headers=headers) + +/** + * Prepares a query to fetch users from the Authentik API. + * Optionally filters by group IDs. + * + * @param list/group_ids List of group IDs to filter by. + * @param page The page number to fetch. + */ +/datum/http_request/authentik_api/proc/prepare_users_query(list/group_ids, page = 1) + // Build query string with group filters + var/group_filter = "" + if (length(group_ids)) + for (var/i = 1; i <= length(group_ids); i++) + if (i > 1) + group_filter += "&" + group_filter += "groups_by_pk=[group_ids[i]]" + + var/url = "[GLOB.config.authentik_api_url]/core/users/?[group_filter]&page=[page]" + var/list/headers = list("Authorization" = "Bearer [GLOB.config.authentik_api_key]") + prepare(RUSTG_HTTP_METHOD_GET, url, headers=headers) + +/datum/http_request/authentik_api/into_response() + var/datum/http_response/R = ..() + + if (R.errored) + return R + + try + R.body = json_decode(R.body) + catch + R.errored = TRUE + R.error = "Malformed JSON returned." + return R + + // Note: Authentik returns paginated results + // Body structure: {"pagination": {...}, "results": [...]} + // We need to handle pagination in the calling code + + return R diff --git a/code/modules/http/forumuser_api.dm b/code/modules/http/forumuser_api.dm deleted file mode 100644 index 915c3e26cc3..00000000000 --- a/code/modules/http/forumuser_api.dm +++ /dev/null @@ -1,67 +0,0 @@ -var/global/forumuser_api_key = null - -/datum/forum_user - var/forum_member_id - var/forum_name - var/forum_primary_group - var/list/forum_secondary_groups = list() - var/discord_id - var/ckey - var/psync_game_disabled = FALSE - -/datum/forum_user/New(data) - forum_member_id = data["forum_member_id"] - forum_name = data["forum_name"] - forum_primary_group = data["forum_primary_group"] - - for (var/id in splittext(data["forum_secondary_groups"], ",")) - forum_secondary_groups += text2num(id) - - discord_id = data["discord_id"] - ckey = data["ckey"] - if(data["psync_game_disabled"] == 1) //This here is needed because the data can be null, 1 or 0 - psync_game_disabled = TRUE - -/datum/http_request/forumuser_api - -/datum/http_request/forumuser_api/proc/prepare_roles_query(role_id) - var/url = "[GLOB.config.forumuser_api_url]/staff/[role_id]" - - var/list/headers = list("Authorization" = "Bearer [forumuser_api_key]") - - prepare(RUSTG_HTTP_METHOD_GET, url, headers=headers) - -/datum/http_request/forumuser_api/proc/prepare_user_discord(discord_id) - var/url = "[GLOB.config.forumuser_api_url]/user/discord/[discord_id]" - - var/list/headers = list("Authorization" = "Bearer [forumuser_api_key]") - - prepare(RUSTG_HTTP_METHOD_GET, url, headers=headers) - -/datum/http_request/forumuser_api/proc/prepare_user_ckey(ckey) - var/url = "[GLOB.config.forumuser_api_url]/user/ckey/[ckey]" - - var/list/headers = list("Authorization" = "Bearer [forumuser_api_key]") - - prepare(RUSTG_HTTP_METHOD_GET, url, headers=headers) - -/datum/http_request/forumuser_api/into_response() - var/datum/http_response/R = ..() - - if (R.errored) - return R - - try - R.body = json_decode(R.body) - catch - R.errored = TRUE - R.error = "Malformed JSON returned." - return R - - var/list/users = list() - for (var/d in R.body) - users += new /datum/forum_user(d) - - R.body = users - - return R diff --git a/code/modules/world_api/commands/server_management.dm b/code/modules/world_api/commands/server_management.dm index 515045901b2..3881390a65d 100644 --- a/code/modules/world_api/commands/server_management.dm +++ b/code/modules/world_api/commands/server_management.dm @@ -130,8 +130,8 @@ /datum/topic_command/admins_reload/run_command(queryparams) log_and_message_admins("AdminRanks: remote reload of the admins list initiated.") - if (GLOB.config.use_forumuser_api) - if (!update_admins_from_api(reload_once_done=FALSE)) + if (GLOB.config.use_authentik_api) + if (!SSauth.update_admins_from_authentik(reload_once_done=TRUE)) statuscode = 500 response = "Updating admins from the forumuser API failed. Aborted." return FALSE @@ -141,6 +141,4 @@ else statuscode = 200 response = "Admins reloaded." - - load_admins() return TRUE diff --git a/config/example/admin_ranks.json b/config/example/admin_ranks.json index 17e8dfbcb99..c1100e74762 100644 --- a/config/example/admin_ranks.json +++ b/config/example/admin_ranks.json @@ -1,13 +1,11 @@ [ { - "role_id": 4, "name": "Head Admin/Dev", "auths": [ "R_ALL" ] }, { - "role_id": 18, "name": "Primary Administrator", "auths": [ "R_BUILDMODE", "R_ADMIN", "R_BAN", "R_FUN", "R_STEALTH", "R_SERVER", @@ -16,7 +14,6 @@ ] }, { - "role_id": 8, "name": "Secondary Administrator", "auths": [ "R_BUILDMODE", "R_ADMIN", "R_BAN", "R_FUN", "R_STEALTH", "R_SERVER", @@ -25,35 +22,30 @@ ] }, { - "role_id": 6, "name": "Moderator", "auths": [ "R_MODERATOR", "R_BAN", "R_SPAWN" ] }, { - "role_id": 22, "name": "Trial Moderator", "auths": [ "R_MODERATOR" ] }, { - "role_id": 9, "name": "Developer", "auths": [ "R_DEVELOPER" ] }, { - "role_id": 13, "name": "CCIA Agent", "auths": [ "R_CCIAA" ] }, { - "role_id": 16, "name": "CCIA Leader", "auths": [ "R_CCIAA" diff --git a/html/changelogs/arrow768-auth-system-change.yml b/html/changelogs/arrow768-auth-system-change.yml new file mode 100644 index 00000000000..7fbf0ec1b94 --- /dev/null +++ b/html/changelogs/arrow768-auth-system-change.yml @@ -0,0 +1,58 @@ +################################ +# Example Changelog File +# +# Note: This file, and files beginning with ".", and files that don't end in ".yml" will not be read. If you change this file, you will look really dumb. +# +# Your changelog will be merged with a master changelog. (New stuff added only, and only on the date entry for the day it was merged.) +# When it is, any changes listed below will disappear. +# +# Valid Prefixes: +# bugfix +# - (fixes bugs) +# wip +# - (work in progress) +# qol +# - (quality of life) +# soundadd +# - (adds a sound) +# sounddel +# - (removes a sound) +# rscadd +# - (adds a feature) +# rscdel +# - (removes a feature) +# imageadd +# - (adds an image or sprite) +# imagedel +# - (removes an image or sprite) +# spellcheck +# - (fixes spelling or grammar) +# experiment +# - (experimental change) +# balance +# - (balance changes) +# code_imp +# - (misc internal code change) +# refactor +# - (refactors code) +# config +# - (makes a change to the config files) +# admin +# - (makes changes to administrator tools) +# server +# - (miscellaneous changes to server) +################################# + +# Your name. +author: arrow768 + +# Optional: Remove this file after generating master changelog. Useful for PR changelogs that won't get used again. +delete-after: True + +# Any changes you've made. See valid prefix list above. +# INDENT WITH TWO SPACES. NOT TABS. SPACES. +# SCREW THIS UP AND IT WON'T WORK. +# Also, this gets changed to [] after reading. Just remove the brackets when you add new shit. +# Please surround your changes in double quotes ("). It works without them, but if you use certain characters it screws up compiling. The quotes will not show up in the changelog. +changes: + - server: "Use the new login system to fetch the admins/groups instead of the deprecated forum user api." diff --git a/tools/ci/check_grep.sh b/tools/ci/check_grep.sh index f50fd048a99..537da068bcc 100755 --- a/tools/ci/check_grep.sh +++ b/tools/ci/check_grep.sh @@ -200,7 +200,7 @@ echo "Verifying no new unmanaged globals are being added" >> code_error.log UNMANAGED_GLOBAL_VARS_REGEXP='^/*var/' UNMANAGED_GLOBAL_VARS_COUNT=`grep -r -o --include \*.dm -P --regexp=$UNMANAGED_GLOBAL_VARS_REGEXP | wc -l` -if [[ $UNMANAGED_GLOBAL_VARS_COUNT -ne 176 ]]; then # THE COUNT CAN ONLY BE DECREASED, NEVER INCREASED +if [[ $UNMANAGED_GLOBAL_VARS_COUNT -ne 173 ]]; then # THE COUNT CAN ONLY BE DECREASED, NEVER INCREASED ERROR_COUNT=$(($ERROR_COUNT+1)) echo "FAIL: New unmanaged global vars, found $UNMANAGED_GLOBAL_VARS_COUNT of them! Use GLOB or update the count ONLY IF YOU ARE REMOVING THEM!" >> code_error.log grep -r --include \*.dm -P --regexp=$UNMANAGED_GLOBAL_VARS_REGEXP >> code_error.log diff --git a/tools/dreamchecker/dreamchecker.exe b/tools/dreamchecker/dreamchecker.exe index 00509134b16..480641bd6d9 100644 Binary files a/tools/dreamchecker/dreamchecker.exe and b/tools/dreamchecker/dreamchecker.exe differ