declarative creation of ldap users and groups

This commit is contained in:
ibizaman 2024-07-18 19:36:40 +02:00
parent 757d910d13
commit b34a1e57a7
3 changed files with 676 additions and 3 deletions

View file

@ -24,6 +24,12 @@
# Remove when this PR is merged:
# https://github.com/NixOS/nixpkgs/pull/368325
./patches/prometheusnodecertexporter.nix
(originPkgs.fetchpatch {
url = "https://patch-diff.githubusercontent.com/raw/NixOS/nixpkgs/pull/374905.patch";
hash = "sha256-9n3HYu0QM+MVLJ3FyOHAd1vdtoK+U5wNQiUwz3L6Bqs=";
})
];
patchedNixpkgs = originPkgs.applyPatches {
name = "nixpkgs-patched";

View file

@ -6,6 +6,35 @@ let
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
lldap-cli = pkgs.lldap-cli.overrideAttrs (f: p: {
version = "0-unstable-2024-01-19";
src = pkgs.fetchFromGitHub {
owner = "ibizaman";
repo = "lldap-cli";
rev = "e07e2cafaa68e926f1256449e4553b01a15f0a0c";
hash = "sha256-qbXSJmx5spW9iViycRmQdYtI6KfysOxeAv7qI+sz25A=";
};
});
lldap-cli-auth = pkgs.callPackage ({ stdenvNoCC, makeWrapper }: stdenvNoCC.mkDerivation {
name = "lldap-cli";
src = lldap-cli;
nativeBuildInputs = [
makeWrapper
];
# No quotes around the value for LLDAP_PASSWORD because we want the value to not be enclosed in quotes.
installPhase = ''
makeWrapper ${lldap-cli}/bin/lldap-cli $out/bin/lldap-cli \
--set LLDAP_USERNAME "admin" \
--run 'export LLDAP_PASSWORD="$(cat ${cfg.ldapUserPassword.result.path})"' \
--set LLDAP_HTTPURL "http://${config.services.lldap.settings.http_host}:${toString config.services.lldap.settings.http_port}"
'';
}) {};
in
{
options.shb.ldap = {
@ -118,6 +147,133 @@ in
};
};
};
groups = lib.mkOption {
description = "LDAP Groups to manage declaratively.";
default = {};
example = lib.literalExpression ''
{
family = {};
}
'';
type = lib.types.attrsOf (lib.types.submodule {
options = {};
});
};
deleteUnmanagedUsers = lib.mkOption {
description = "Do not delete users that are not defined here.";
type = lib.types.bool;
default = false;
};
users = lib.mkOption {
description = ''
LDAP Users to manage declaratively.
Each field is provided in two versions.
The first version, without prefix, sets the attribute
every time the configuration is applied, overwriting any changes in the UI.
The second version, with the "initial" prefix, sets
the attribute only once, on user creation. Any changes
in the UI will survive next time the configuration is applied.
If both are set, the one without the "initial" prefix wins.
'';
default = {};
type = lib.types.attrsOf (lib.types.submodule {
options = {
email = lib.mkOption {
description = ''
Email address.
Must be set: set either "email" field or "initialEmail" field.
'';
type = lib.types.nullOr lib.types.str;
default = null;
};
initialEmail = lib.mkOption {
description = ''
Email address.
Must be set: set either "email" field or "initialEmail" field.
'';
type = lib.types.nullOr lib.types.str;
default = null;
};
displayName = lib.mkOption {
description = "Display name. Optional";
type = lib.types.nullOr lib.types.str;
default = null;
};
initialDisplayName = lib.mkOption {
description = "Display name. Optional";
type = lib.types.nullOr lib.types.str;
default = null;
};
firstName = lib.mkOption {
description = "First name. Optional";
type = lib.types.nullOr lib.types.str;
default = null;
};
initialFirstName = lib.mkOption {
description = "First name. Optional";
type = lib.types.nullOr lib.types.str;
default = null;
};
lastName = lib.mkOption {
description = "Last name. Optional.";
type = lib.types.nullOr lib.types.str;
default = null;
};
initialLastName = lib.mkOption {
description = "Last name. Optional.";
type = lib.types.nullOr lib.types.str;
default = null;
};
groups = lib.mkOption {
description = "Groups this user is member of. The group must exist.";
type = lib.types.listOf lib.types.str;
default = [];
};
password = lib.mkOption {
description = "User password. Optional.";
type = lib.types.nullOr (lib.types.submodule {
options = contracts.secret.mkRequester {
mode = "0440";
owner = "lldap";
group = "lldap";
restartUnits = [ "lldap.service" ];
};
});
default = null;
};
initialPassword = lib.mkOption {
description = "User password. Optional.";
type = lib.types.nullOr (lib.types.submodule {
options = contracts.secret.mkRequester {
mode = "0440";
owner = "lldap";
group = "lldap";
restartUnits = [ "lldap.service" ];
};
});
default = null;
};
};
});
};
};
@ -171,5 +327,188 @@ in
verbose = cfg.debug;
};
};
environment.systemPackages = [
lldap-cli-auth
];
# $ lldap-cli schema attribute user list
#
# Name Type Is list Is visible Is editable
# ---- ---- ------- ---------- -----------
# avatar JpegPhoto false true true
# creation_date DateTime false true false
# display_name String false true true
# first_name String false true true
# last_name String false true true
# mail String false true true
# user_id String false true false
# uuid String false true false
# $ lldap-cli schema attribute group list
#
# Name Type Is list Is visible Is editable
# ---- ---- ------- ---------- -----------
# creation_date DateTime false true false
# display_name String false true true
# group_id Integer false true false
# uuid String false true false
assertions = [
(let
uppercases = builtins.filter (n: lib.strings.toLower n != n) (lib.mapAttrsToList (uid: u: uid) cfg.users);
in {
assertion = uppercases == [];
message = "Users ID for LLDAP can only be lowercase, found: ${lib.concatStringsSep "," uppercases}";
})
# (let
# unknownGroups = lib.flatten(lib.mapAttrsToList (uid: u: lib.subtractLists (map (gid: g: gid) cfg.groups) u.groups) cfg.users);
# in {
# assertion = unknownGroups == [];
# message = "All groups defined in user field must exist, the following ones are not defined: ${lib.concatStringsSep "," unknownGroups}";
# })
];
systemd.services.lldap.postStart =
let
configFile = (pkgs.formats.toml {}).generate "lldap_config.toml" config.services.lldap.settings;
login = [''
set -euo pipefail
sleep 3
export LLDAP_USERNAME=admin
export LLDAP_PASSWORD=$(cat ${cfg.ldapUserPassword.result.path})
export LLDAP_HTTPURL=http://${config.services.lldap.settings.http_host}:${toString config.services.lldap.settings.http_port}
eval $(${lldap-cli}/bin/lldap-cli login)
''];
deleteGroups = [''
allUids=(${lib.concatStringsSep " " (
(lib.mapAttrsToList (id: g: id) cfg.groups)
++ [ "lldap_admin" "lldap_password_manager" "lldap_strict_readonly" ])
})
echo All managed groups are: ''${allUids[*]}
echo Other groups will be deleted if any
for uid in $(${lldap-cli}/bin/lldap-cli group list | ${pkgs.jq}/bin/jq -r 'map(.displayName)[]'); do
if [[ ! " ''${allUids[*]} " =~ [[:space:]]''${uid}[[:space:]] ]]; then
echo Deleting group $uid
${lldap-cli}/bin/lldap-cli group del $uid
fi
done
''];
createGroups = [''
existingUids=$(${lldap-cli}/bin/lldap-cli group list | ${pkgs.jq}/bin/jq -r 'map(.displayName)[]')
managedUids=(${lib.concatStringsSep " " (
(lib.mapAttrsToList (id: g: id) cfg.groups)
++ [ "lldap_admin" "lldap_password_manager" "lldap_strict_readonly" ])
})
echo All managed groups are: ''${managedUids[*]}
for uid in ''${managedUids[*]}; do
if [[ ! " ''${existingUids[*]} " =~ [[:space:]]''${uid}[[:space:]] ]]; then
echo "Creating group $uid"
${lldap-cli}/bin/lldap-cli group add $uid
fi
done
''];
deleteUsers = [''
allUids=(${lib.concatStringsSep " " (
(lib.mapAttrsToList (uid: u: uid) cfg.users)
++ [ "admin" ])
})
echo All managed users are: ''${allUids[*]}
echo Other users will be deleted if any
for uid in $(${lldap-cli}/bin/lldap-cli user list all | ${pkgs.jq}/bin/jq -r 'map(.id)[]'); do
if [[ ! " ''${allUids[*]} " =~ [[:space:]]''${uid}[[:space:]] ]]; then
echo Deleting user $uid
${lldap-cli}/bin/lldap-cli user del $uid
fi
done
''];
createUsers = lib.mapAttrsToList (uid: u: let
email = if u.email != null then u.email else u.initialEmail;
password = if u.password != null then u.password else u.initialPassword;
displayName = if u.displayName != null then u.displayName else u.initialDisplayName;
firstName = if u.firstName != null then u.firstName else u.initialFirstName;
lastName = if u.lastName != null then u.lastName else u.initialLastName;
allCreateAttributes =
lib.optionals (password != null) [''-p "$(cat ${password.result.path})"'']
++ lib.optionals (displayName != null) [''-d "${displayName}"'']
++ lib.optionals (firstName != null) [''-f "${firstName}"'']
++ lib.optionals (lastName != null) [''-l "${lastName}"''];
allUpdateAttributes =
lib.optionals (u.email != null) [''mail "${u.email}"'']
++ lib.optionals (u.password != null) [''password "$(cat ${u.password.result.path})"'']
++ lib.optionals (u.displayName != null) [''display_name "${u.displayName}"'']
++ lib.optionals (u.firstName != null) [''first_name "${u.firstName}"'']
++ lib.optionals (u.lastName != null) [''last_name "${u.lastName}"''];
in ''
existingUids=($(${lldap-cli}/bin/lldap-cli user list all | ${pkgs.jq}/bin/jq -r 'map(.id)[]'))
managedUids=(${lib.concatStringsSep " " (
(lib.mapAttrsToList (id: u: id) cfg.users)
++ [ "admin" ])
})
echo All managed users are: ''${managedUids[*]}
echo Existing managed users are: ''${existingUids[*]}
set -x
for uid in ''${managedUids[*]}; do
echo "Checking user $uid"
if [[ ! " ''${existingUids[*]} " =~ [[:space:]]''${uid}[[:space:]] ]]; then
echo "Creating user $uid"
${lldap-cli}/bin/lldap-cli user add ${uid} "${email}" ${lib.concatStringsSep " " allCreateAttributes}
else
echo "Updating user $uid"
${lib.concatMapStringsSep "\n " (x: "${lldap-cli}/bin/lldap-cli user update set ${uid} ${x}") allUpdateAttributes}
fi
done
set +x
'') cfg.users;
addToGroups = lib.mapAttrsToList (uid: u: ''
existingUids=$(${lldap-cli}/bin/lldap-cli user group list ${uid})
managedUids=(${lib.concatStringsSep " " u.groups})
echo All managed groups for user ${uid} are: ''${managedUids[*]}
for gid in ''${managedUids[*]}; do
if [[ ! " ''${existingUids[*]} " =~ [[:space:]]''${gid}[[:space:]] ]]; then
echo "Adding group $gid to user ${uid}"
${lldap-cli}/bin/lldap-cli user group add \
${uid} \
$gid
fi
done
'') cfg.users;
deleteFromGroups = lib.mapAttrsToList (uid: u: ''
managedUids=(${lib.concatStringsSep " " u.groups})
echo All managed groups for user ${uid} are: ''${managedUids[*]}
for gid in $(${lldap-cli}/bin/lldap-cli user group list ${uid}); do
if [[ ! " ''${managedUids[*]} " =~ [[:space:]]''${gid}[[:space:]] ]]; then
echo "Removing group $gid from user $uid"
${lldap-cli}/bin/lldap-cli user group del \
${uid} \
$gid
fi
done
'') cfg.users;
in
lib.concatStringsSep "\n\n" (
login
++ deleteGroups
++ createGroups
++ lib.optionals cfg.deleteUnmanagedUsers deleteUsers
++ createUsers
++ addToGroups
++ deleteFromGroups
);
};
}

View file

@ -28,7 +28,8 @@ in
domain = "example.com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
debug = true;
deleteUnmanagedUsers = true;
debug = false; # Really verbose.
};
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.ldap.ldapUserPassword.request;
@ -39,13 +40,131 @@ in
settings.content = "jwtSecret";
};
specialisation.withGroupA.configuration = {
shb.ldap = {
groups."groupA" = {};
};
};
specialisation.withUserA.configuration = {
shb.ldap = {
groups."groupA" = {};
users = {
"user_a" = {
email = "userA@test.com";
displayName = "UserA";
firstName = "User";
lastName = "A";
groups = [ "groupA" ];
password.result = config.specialisation.withUserA.configuration.shb.hardcodedsecret.user_a.result;
};
};
};
shb.hardcodedsecret.user_a = {
request = config.specialisation.withUserA.configuration.shb.ldap.users."user_a".password.request;
settings.content = "userpasswordA"; # must be longer than 8 characters.
};
};
specialisation.withOtherGroup.configuration = {
shb.ldap = {
groups."groupB" = {};
groups."groupC" = {};
users = {
"user_a" = {
email = "userA@test.com";
displayName = "UserA";
firstName = "User";
lastName = "A";
groups = [ "groupB" "groupC" ];
password.result = config.specialisation.withUserA.configuration.shb.hardcodedsecret.user_a.result;
};
};
};
shb.hardcodedsecret.user_a = {
request = config.specialisation.withUserA.configuration.shb.ldap.users."user_a".password.request;
settings.content = "userpasswordA"; # must be longer than 8 characters.
};
};
specialisation.removeFromGroup.configuration = {
shb.ldap = {
groups."groupB" = {};
groups."groupC" = {};
users = {
"user_a" = {
email = "userA@test.com";
displayName = "UserA";
firstName = "User";
lastName = "A";
groups = [ "groupB" ];
password.result = config.specialisation.withUserA.configuration.shb.hardcodedsecret.user_a.result;
};
};
};
shb.hardcodedsecret.user_a = {
request = config.specialisation.withUserA.configuration.shb.ldap.users."user_a".password.request;
settings.content = "userpasswordA"; # must be longer than 8 characters.
};
};
specialisation.changeAttributes.configuration = {
shb.ldap = {
groups."groupB" = {};
users = {
"user_a" = {
email = "userA_2@test.com";
displayName = "UserA_2";
firstName = "User_2";
lastName = "A_2";
groups = [ "groupB" ];
password.result = config.specialisation.withUserA.configuration.shb.hardcodedsecret.user_a.result;
};
};
};
shb.hardcodedsecret.user_a = {
request = config.specialisation.withUserA.configuration.shb.ldap.users."user_a".password.request;
settings.content = "userpasswordA_2"; # must be longer than 8 characters.
};
};
specialisation.noChangeAttributes.configuration = {
shb.ldap = {
groups."groupB" = {};
users = {
"user_a" = {
initialEmail = "userA_3@test.com";
initialDisplayName = "UserA_3";
initialFirstName = "User_3";
initialLastName = "A_3";
groups = [ "groupB" ];
initialPassword.result = config.specialisation.withUserA.configuration.shb.hardcodedsecret.user_a.result;
};
};
};
shb.hardcodedsecret.user_a = {
request = config.specialisation.withUserA.configuration.shb.ldap.users."user_a".password.request;
settings.content = "userpasswordA_3"; # must be longer than 8 characters.
};
};
specialisation.leaveUnmanagedUser.configuration = {
shb.ldap = {
deleteUnmanagedUsers = lib.mkForce false;
groups."groupA" = {};
users = {};
};
};
networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port
};
nodes.client = {};
# Inspired from https://github.com/lldap/lldap/blob/33f50d13a2e2d24a3e6bb05a148246bc98090df0/example_configs/lldap-ha-auth.sh
testScript = { nodes, ... }: ''
testScript = { nodes, ... }: let
withGroupA = "${nodes.server.system.build.toplevel}/specialisation/withGroupA";
withUserA = "${nodes.server.system.build.toplevel}/specialisation/withUserA";
withOtherGroup = "${nodes.server.system.build.toplevel}/specialisation/withOtherGroup";
removeFromGroup = "${nodes.server.system.build.toplevel}/specialisation/removeFromGroup";
changeAttributes = "${nodes.server.system.build.toplevel}/specialisation/changeAttributes";
noChangeAttributes = "${nodes.server.system.build.toplevel}/specialisation/noChangeAttributes";
leaveUnmanagedUser = "${nodes.server.system.build.toplevel}/specialisation/leaveUnmanagedUser";
in ''
import json
start_all()
@ -82,13 +201,222 @@ in
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ """ -H "Authorization: Bearer {token}" """.format(token=token)
+ f""" -H "Authorization: Bearer {token}" """
+ " http://server/api/graphql "
+ """ -d '{"variables": {"id": "admin"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """
))['data']
assert data['user']['displayName'] == "Administrator"
assert data['user']['groups'][0]['displayName'] == "lldap_admin"
data = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ f""" -H "Authorization: Bearer {token}" """
+ " http://server/api/graphql "
+ """ -d '{"variables": {"id": "user_a"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """
))['data']
print(server.succeed("cat /run/current-system/sw/bin/lldap-cli"))
with subtest("Add groupA"):
groups = [x["displayName"] for x in json.loads(server.succeed("lldap-cli group list"))]
print("GROUPS=", groups)
if "groupA" in groups:
raise Exception("Shouldn't have found groupA yet.")
server.succeed(
"${withGroupA}/bin/switch-to-configuration test >&2"
)
groups = [x["displayName"] for x in json.loads(server.succeed("lldap-cli group list"))]
print("GROUPS=", groups)
if "groupA" not in groups:
raise Exception("Should have found groupA.")
with subtest("Add user_a"):
users = [x["id"] for x in json.loads(server.succeed("lldap-cli user list all"))]
print("USERS=", users)
if "user_a" in users:
raise Exception("Shouldn't have found user_a yet.")
server.succeed(
"${withUserA}/bin/switch-to-configuration test >&2"
)
users = [x["id"] for x in json.loads(server.succeed("lldap-cli user list all"))]
print("USERS=", users)
if "user_a" not in users:
raise Exception("Should have found user_a.")
groups = server.succeed("lldap-cli user group list user_a").splitlines()
if "groupA" not in groups:
raise Exception("Should have found groupA.")
with subtest("auth with user_a"):
token = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login "
+ """ -d '{"username": "user_a", "password": "userpasswordA"}' """
))['token']
data = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ f""" -H "Authorization: Bearer {token}" """
+ " http://server/api/graphql "
+ """ -d '{"variables": {"id": "user_a"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """
))['data']
if data['user']['displayName'] != "UserA":
raise Exception("DisplayName is not UserA, got:", data['user']['displayName'])
with subtest("With other group"):
server.succeed(
"${withOtherGroup}/bin/switch-to-configuration test >&2"
)
groups = [x["displayName"] for x in json.loads(server.succeed("lldap-cli group list"))]
print("GROUPS=", groups)
if "groupA" in groups:
raise Exception("Should not have found groupA.")
if "groupB" not in groups:
raise Exception("Should have found groupB.")
if "groupC" not in groups:
raise Exception("Should have found groupC.")
groups = server.succeed("lldap-cli user group list user_a").splitlines()
if "groupA" in groups:
raise Exception("Should not have found groupA.")
if "groupB" not in groups:
raise Exception("Should have found groupB.")
if "groupC" not in groups:
raise Exception("Should have found groupC.")
with subtest("Remove from group"):
server.succeed(
"${removeFromGroup}/bin/switch-to-configuration test >&2"
)
groups = [x["displayName"] for x in json.loads(server.succeed("lldap-cli group list"))]
print("GROUPS=", groups)
if "groupA" in groups:
raise Exception("Should not have found groupA.")
if "groupB" not in groups:
raise Exception("Should have found groupB.")
if "groupC" not in groups:
raise Exception("Should have found groupC.")
groups = server.succeed("lldap-cli user group list user_a").splitlines()
if "groupA" in groups:
raise Exception("Should not have found groupA.")
if "groupB" not in groups:
raise Exception("Should have found groupB.")
if "groupC" in groups:
raise Exception("Should not have found groupC.")
with subtest("change attributes"):
server.succeed(
"${changeAttributes}/bin/switch-to-configuration test >&2"
)
token = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login "
+ """ -d '{"username": "user_a", "password": "userpasswordA_2"}' """
))['token']
data = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ f""" -H "Authorization: Bearer {token}" """
+ " http://server/api/graphql "
+ """ -d '{"variables": {"id": "user_a"}, "query":"query($id:String!){user(userId:$id){email displayName firstName lastName}}"}' """
))['data']
if data['user']['email'] != "userA_2@test.com":
raise Exception("email is not userA_2@test.com_2, got:", data['user']['email'])
if data['user']['displayName'] != "UserA_2":
raise Exception("displayName is not UserA_2, got:", data['user']['displayName'])
if data['user']['firstName'] != "User_2":
raise Exception("firstName is not User_2_2, got:", data['user']['firstName'])
if data['user']['lastName'] != "A_2":
raise Exception("lastName is not A_2, got:", data['user']['lastName'])
with subtest("do not change attributes"):
server.succeed(
"${noChangeAttributes}/bin/switch-to-configuration test >&2"
)
token = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login "
+ """ -d '{"username": "user_a", "password": "userpasswordA_2"}' """
))['token']
data = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ f""" -H "Authorization: Bearer {token}" """
+ " http://server/api/graphql "
+ """ -d '{"variables": {"id": "user_a"}, "query":"query($id:String!){user(userId:$id){email displayName firstName lastName}}"}' """
))['data']
if data['user']['email'] != "userA_2@test.com":
raise Exception("email is not userA_2@test.com_2, got:", data['user']['email'])
if data['user']['displayName'] != "UserA_2":
raise Exception("displayName is not UserA_2, got:", data['user']['displayName'])
if data['user']['firstName'] != "User_2":
raise Exception("firstName is not User_2_2, got:", data['user']['firstName'])
if data['user']['lastName'] != "A_2":
raise Exception("lastName is not A_2, got:", data['user']['lastName'])
with subtest("Delete user"):
server.succeed(
"${withGroupA}/bin/switch-to-configuration test >&2"
)
users = [x["id"] for x in json.loads(server.succeed("lldap-cli user list all"))]
print("USERS=", users)
if "user_a" in users:
raise Exception("Should not have found user_a.")
with subtest("Add again user_a"):
server.succeed(
"${withUserA}/bin/switch-to-configuration test >&2"
)
users = [x["id"] for x in json.loads(server.succeed("lldap-cli user list all"))]
print("USERS=", users)
if "user_a" not in users:
raise Exception("Should have found user_a.")
server.succeed(
"${leaveUnmanagedUser}/bin/switch-to-configuration test >&2"
)
users = [x["id"] for x in json.loads(server.succeed("lldap-cli user list all"))]
print("USERS=", users)
if "user_a" not in users:
raise Exception("Should have found user_a.")
'';
};
}