diff --git a/flake.nix b/flake.nix index 3becf22a..5f1029cd 100644 --- a/flake.nix +++ b/flake.nix @@ -566,6 +566,7 @@ foreman glibcLocales netcat-openbsd + openldap python3 ]; @@ -591,6 +592,8 @@ ] ++ lib.optionals stdenv.isLinux [ rpm dpkg cdrkit ] ); + OPENLDAP_ROOT = openldap; + shellHook = '' pushd $(git rev-parse --show-toplevel) >/dev/null @@ -922,178 +925,6 @@ ''; }; - tests.ldap.x86_64-linux = - with import (nixpkgs + "/nixos/lib/testing-python.nix") { system = "x86_64-linux"; }; - makeTest { - machine = { pkgs, ... }: { - imports = [ hydraServer ]; - - services.openldap.enable = true; - services.openldap.settings.children = { - "cn=schema".includes = [ - "${pkgs.openldap}/etc/schema/core.ldif" - "${pkgs.openldap}/etc/schema/cosine.ldif" - "${pkgs.openldap}/etc/schema/inetorgperson.ldif" - "${pkgs.openldap}/etc/schema/nis.ldif" - ]; - - "olcDatabase={1}mdb".attrs = { - objectClass = [ "olcDatabaseConfig" "olcMdbConfig" ]; - olcDatabase = "{1}mdb"; - olcSuffix = "dc=example"; - olcRootDN = "cn=root,dc=example"; - olcRootPW = "notapassword"; - olcDbDirectory = "/var/lib/openldap"; - }; - }; - - # userPassword generated via `slappasswd` - # The admin user has the password `password` and `user` has the password `foobar`. - services.openldap.declarativeContents."dc=example" = '' - dn: dc=example - dc: example - o: Root - objectClass: top - objectClass: dcObject - objectClass: organization - - - dn: ou=users,dc=example - ou: users - description: All users - objectClass: top - objectClass: organizationalUnit - - dn: ou=groups,dc=example - ou: groups - description: All groups - objectClass: top - objectClass: organizationalUnit - - dn: cn=hydra_admin,ou=groups,dc=example - cn: hydra_admin - description: Hydra Admin user group - objectClass: groupOfNames - member: cn=admin,ou=users,dc=example - - dn: cn=hydra-admin,ou=groups,dc=example - cn: hydra-admin - description: Users who are NOT Hydra Admins because the prefix needs to be a _ - objectClass: groupOfNames - member: cn=notadmin,ou=users,dc=example - - dn: cn=user,ou=users,dc=example - objectClass: organizationalPerson - objectClass: inetOrgPerson - sn: user - cn: user - mail: user@example - userPassword: {SSHA}gLgBMb86/3wecoCp8gtORgIF2/qCRpqs - - dn: cn=admin,ou=users,dc=example - objectClass: organizationalPerson - objectClass: inetOrgPerson - sn: admin - cn: admin - mail: admin@example - userPassword: {SSHA}BsgOQcRnoiULzwLrGmuzVGH6EC5Dkwmf - - dn: cn=notadmin,ou=users,dc=example - objectClass: organizationalPerson - objectClass: inetOrgPerson - sn: notadmin - cn: notadmin - mail: notadmin@example - userPassword: {SSHA}BsgOQcRnoiULzwLrGmuzVGH6EC5Dkwmf - - ''; - systemd.services.hydra-server.environment.CATALYST_DEBUG = "1"; - systemd.services.hydra-server.environment.HYDRA_LDAP_CONFIG = pkgs.writeText "config.yaml" - # example config based on https://metacpan.org/source/ILMARI/Catalyst-Authentication-Store-LDAP-1.016/README#L103 - '' - credential: - class: Password - password_field: password - password_type: self_check - store: - class: LDAP - ldap_server: localhost - ldap_server_options: - timeout: 30 - debug: 2 - binddn: "cn=root,dc=example" - bindpw: notapassword - start_tls: 0 - start_tls_options: - verify: none - user_basedn: "ou=users,dc=example" - user_filter: "(&(objectClass=inetOrgPerson)(cn=%s))" - user_scope: one - user_field: cn - user_search_options: - deref: always - use_roles: 1 - role_basedn: "ou=groups,dc=example" - role_filter: "(&(objectClass=groupOfNames)(member=%s))" - role_scope: one - role_field: cn - role_value: dn - role_search_options: - deref: always - ''; - networking.firewall.enable = false; - }; - testScript = '' - import json - from pprint import pprint - - machine.wait_for_unit("openldap.service") - machine.wait_for_job("hydra-init") - machine.wait_for_open_port("3000") - - print("Logging in as a regular user:") - response = machine.succeed( - "curl --fail http://localhost:3000/login -H 'Accept: application/json' -H 'Referer: http://localhost:3000' --data 'username=user&password=foobar'" - ) - - response_json = json.loads(response) - pprint(response_json) - assert "user" == response_json["username"] - assert "user@example" == response_json["emailaddress"] - assert len(response_json["userroles"]) == 0 - - # logging on with wrong credentials shouldn't work - print("Logging in with bad creds:") - machine.fail( - "curl --fail http://localhost:3000/login -H 'Accept: application/json' -H 'Referer: http://localhost:3000' --data 'username=user&password=wrongpassword'" - ) - - - # the admin user should get the admin role from his group membership in `hydra_admin` - print("Logging in as an admin user:") - response = machine.succeed( - "curl --fail http://localhost:3000/login -H 'Accept: application/json' -H 'Referer: http://localhost:3000' --data 'username=admin&password=password'" - ) - - response_json = json.loads(response) - pprint(response_json) - assert "admin" == response_json["username"] - assert "admin@example" == response_json["emailaddress"] - assert "admin" in response_json["userroles"] - - # the notadmin user should NOT get the admin role from their group membership in `hydra-admin` - response = machine.succeed( - "curl --fail http://localhost:3000/login -H 'Accept: application/json' -H 'Referer: http://localhost:3000' --data 'username=notadmin&password=password'" - ) - - response_json = json.loads(response) - pprint(response_json) - assert "notadmin" == response_json["username"] - assert "notadmin@example" == response_json["emailaddress"] - assert "admin" not in response_json["userroles"] - ''; - }; - tests.validate-openapi = pkgs.runCommand "validate-openapi" { buildInputs = [ pkgs.openapi-generator-cli ]; } '' diff --git a/src/lib/Hydra/Helper/Exec.pm b/src/lib/Hydra/Helper/Exec.pm index 3e24d60d..e4702ef7 100644 --- a/src/lib/Hydra/Helper/Exec.pm +++ b/src/lib/Hydra/Helper/Exec.pm @@ -7,8 +7,28 @@ our @ISA = qw(Exporter); our @EXPORT = qw( captureStdoutStderr captureStdoutStderrWithStdin + expectOkay ); +sub expectOkay { + my ($timeout, @cmd) = @_; + + my ($res, $stdout, $stderr) = captureStdoutStderrWithStdin($timeout, \@cmd, ""); + if ($res) { + die <new(); +my $users = { + unrelated => $ldap->add_user("unrelated_user"), + admin => $ldap->add_user("admin_user"), + not_admin => $ldap->add_user("not_admin_user"), + many_roles => $ldap->add_user("many_roles"), +}; + +$ldap->add_group("hydra_admin", $users->{"admin"}->{"username"}); +$ldap->add_group("hydra-admin", $users->{"not_admin"}->{"username"}); + +$ldap->add_group("hydra_create-projects", $users->{"many_roles"}->{"username"}); +$ldap->add_group("hydra_restart-jobs", $users->{"many_roles"}->{"username"}); +$ldap->add_group("hydra_bump-to-front", $users->{"many_roles"}->{"username"}); +$ldap->add_group("hydra_cancel-build", $users->{"many_roles"}->{"username"}); + +my $hydra_ldap_config = "${\$ldap->tmpdir()}/hydra_ldap_config.yaml"; +LDAPContext::write_file($hydra_ldap_config, <server_url()}" + ldap_server_options: + timeout: 30 + debug: 0 + binddn: "cn=root,dc=example" + bindpw: notapassword + start_tls: 0 + start_tls_options: + verify: none + user_basedn: "ou=users,dc=example" + user_filter: "(&(objectClass=inetOrgPerson)(cn=%s))" + user_scope: one + user_field: cn + user_search_options: + deref: always + use_roles: 1 + role_basedn: "ou=groups,dc=example" + role_filter: "(&(objectClass=groupOfNames)(member=%s))" + role_scope: one + role_field: cn + role_value: dn + role_search_options: + deref: always +YAML + +$ENV{'HYDRA_LDAP_CONFIG'} = $hydra_ldap_config; +my $ctx = test_context(); + +Catalyst::Test->import('Hydra'); + +subtest "Valid login attempts" => sub { + my %users_to_roles = ( + unrelated => [], + admin => ["admin"], + not_admin => [], + many_roles => [ "create-projects", "restart-jobs", "bump-to-front", "cancel-build" ], + ); + for my $username (keys %users_to_roles) { + my $user = $users->{$username}; + my $roles = $users_to_roles{$username}; + + subtest "Verifying $username" => sub { + my $req = request(POST '/login', + Referer => 'http://localhost/', + Accept => 'application/json', + Content => { + username => $user->{"username"}, + password => $user->{"password"} + } + ); + + is($req->code, 302, "The login redirects"); + my $data = decode_json($req->content()); + is($data->{"username"}, $user->{"username"}, "Username matches"); + is($data->{"emailaddress"}, $user->{"email"}, "Email matches"); + is([sort @{$data->{"userroles"}}], [sort @$roles], "Roles match"); + }; + } +}; + +# Logging in with an invalid user is rejected +is(request(POST '/login', + Referer => 'http://localhost/', + Content => { + username => 'alice', + password => 'foobar' + } +)->code, 403, "Logging in with invalid credentials does not work"); + + + +done_testing; diff --git a/t/lib/LDAPContext.pm b/t/lib/LDAPContext.pm new file mode 100644 index 00000000..e3928a51 --- /dev/null +++ b/t/lib/LDAPContext.pm @@ -0,0 +1,237 @@ +use strict; +use warnings; + +package LDAPContext; +use POSIX qw(SIGKILL); +use File::Path qw(make_path); +use WWW::Form::UrlEncoded::PP qw(); +use Hydra::Helper::Exec; + +# Set up an LDAP server to run during the test. +# +# It creates a top level organization and structure, and provides +# add_user and add_group. +# +# The server is automatically terminated when the class is dropped. +sub new { + my ($class) = @_; + + my $root = File::Temp->newdir(); + mkdir $root; + + my $pid_file = "$root/slapd.pid"; + + my $slapd_dir = "$root/slap.d"; + mkdir $slapd_dir; + + my $db_dir = "$root/db"; + mkdir $db_dir; + + my $socket = "$root/slapd.socket"; + + my $self = { + _tmpdir => $root, + _db_dir => $db_dir, + _openldap_source => $ENV{"OPENLDAP_ROOT"}, + _pid_file => $pid_file, + _slapd_dir => $slapd_dir, + _socket => $socket, + }; + + my $blessed = bless $self, $class; + $blessed->start(); + + return $blessed; +} + +# Create a user with a specific email address and password +# +# Hash Parameters: +# +# * password: The clear text password, will be hashed when stored in the DB. +# * email: The user's email address. Defaults to $name@example.net +# +# Return: a hash of parameters for the user +# +# * username: The user's provided $name +# * password: The plaintext password, generated if not provided in the arguments +# * hashed_password: The hashed password +# * email: Their email address +sub add_user { + my ($self, $name, %opts) = @_; + + my $email = $opts{'email'} // "$name\@example"; + my $password = $opts{'password'} // rand_chars(); + + my ($res, $stdout, $stderr) = captureStdoutStderr(1, ("slappasswd", "-s", $password)); + if ($res) { + die "Failed to execute slappasswd ($res): $stderr, $stdout"; + } + my $hashedPassword = $stdout; + $hashedPassword =~ s/^\s+|\s+$//g; # Trim whitespace + + $self->load_ldif("dc=example", < $name, + email => $email, + password => $password, + hashed_password => $hashedPassword, + }; +} + +# Create a group with a specific name and members +sub add_group { + my ($self, $name, @users) = @_; + + my $members = join "\n", (map "member: cn=$_,ou=users,dc=example", @users); + + $self->load_ldif("dc=example", <{"_pid_file"}} + +dn: cn=schema,cn=config +cn: schema +objectClass: olcSchemaConfig + +include: file://${\$self->{"_openldap_source"}}/etc/schema/core.ldif +include: file://${\$self->{"_openldap_source"}}/etc/schema/cosine.ldif +include: file://${\$self->{"_openldap_source"}}/etc/schema/inetorgperson.ldif +include: file://${\$self->{"_openldap_source"}}/etc/schema/nis.ldif + +dn: olcDatabase={1}mdb,cn=config +objectClass: olcDatabaseConfig +objectClass: olcMdbConfig +olcDatabase: {1}mdb +olcDbDirectory: ${\$self->{"_db_dir"}} +olcRootDN: cn=root,dc=example +olcRootPW: notapassword +olcSuffix: dc=example +EOF +} + +sub _makeBootstrapOrganization { + my ($self) = @_; + # This has been copied from the generated config used by the + # ldap test in the flake.nix. + return <load_ldif("cn=config", $self->_makeBootstrapConfig()); + $self->load_ldif("dc=example", $self->_makeBootstrapOrganization()); + + $self->_spawn() +} + +sub validateConfig { + my ($self) = @_; + + expectOkay(1, ("slaptest", "-u", "-F", $self->{"_slapd_dir"})); +} + +sub _spawn { + my ($self) = @_; + + my $pid = fork; + die "When starting the LDAP server: failed to fork." if not defined $pid; + + if ($pid == 0) { + exec("${\$self->{'_openldap_source'}}/libexec/slapd", + # A debug flag `-d` must be specified to avoid backgrounding, and an empty + # argument means no additional debugging. + "-d", "", + # "-d", "conns", "-d", "filter", "-d", "config", + "-F", $self->{"_slapd_dir"}, "-h", $self->server_url()) or die "Could not start slapd"; + } else { + $self->{"_pid"} = $pid; + } +} + +sub server_url { + my ($self) = @_; + + my $encoded_socket_path = WWW::Form::UrlEncoded::PP::url_encode($self->{"_socket"}); + + return "ldapi://$encoded_socket_path"; +} + +sub tmpdir { + my ($self) = @_; + + return $self->{"_tmpdir"}; +} + +sub load_ldif { + my ($self, $suffix, $content) = @_; + + my $path = "${\$self->{'_tmpdir'}}/load.ldif"; + write_file($path, $content); + expectOkay(1, ("slapadd", "-F", $self->{"_slapd_dir"}, "-b", $suffix, "-l", $path)); + $self->validateConfig(); +} + +sub DESTROY +{ + my ($self) = @_; + if ($self->{"_pid"}) { + kill SIGKILL, $self->{"_pid"}; + } +} + +sub write_file { + my ($path, $text) = @_; + open(my $fh, '>', $path) or die "Could not open file '$path' $!"; + print $fh $text || ""; + close $fh; +} + +sub rand_chars { + return sprintf("t%08X", rand(0xFFFFFFFF)); +} + +1;