From 9e08eafcfff0021ab4501d03efff8ac09d3d5ffa Mon Sep 17 00:00:00 2001 From: Hugo <10965479+hugoghx@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:42:30 +0100 Subject: [PATCH] feat(credential/vault): Implement LDAP Credential Library --- globals/prefixes.go | 6 + go.mod | 4 +- go.sum | 24 +- .../service_list_libraries_ext_test.go | 116 +-- .../credential/vault/credential_library.go | 14 + internal/credential/vault/docker.go | 10 +- .../vault/ldap_credential_library.go | 105 +++ internal/credential/vault/private_library.go | 266 +++++++ .../credential/vault/private_library_test.go | 210 ++++++ internal/credential/vault/public_ids.go | 10 + internal/credential/vault/query.go | 115 ++- .../repository_ldap_credential_library.go | 224 ++++++ ...repository_ldap_credential_library_test.go | 709 ++++++++++++++++++ internal/credential/vault/store/vault.pb.go | 195 ++++- internal/credential/vault/supported.go | 231 +++++- internal/credential/vault/testing.go | 192 ++++- .../credential/vault/store/v1/vault.proto | 49 ++ 17 files changed, 2375 insertions(+), 105 deletions(-) create mode 100644 internal/credential/vault/ldap_credential_library.go create mode 100644 internal/credential/vault/repository_ldap_credential_library.go create mode 100644 internal/credential/vault/repository_ldap_credential_library_test.go diff --git a/globals/prefixes.go b/globals/prefixes.go index 5286d664f..801b75f5a 100644 --- a/globals/prefixes.go +++ b/globals/prefixes.go @@ -71,6 +71,8 @@ const ( VaultSshCertificateCredentialLibraryPrefix = "clvsclt" // DynamicCredentialPrefix is the prefix for Vault dynamic credentials VaultDynamicCredentialPrefix = "cdvlt" + // VaultLdapCredentialLibraryPrefix is the prefix for Vault Ldap credential libraries + VaultLdapCredentialLibraryPrefix = "clvllt" // UsernamePasswordCredentialPrefix is the prefix for username/password // creds @@ -243,6 +245,10 @@ var prefixToResourceType = map[string]ResourceInfo{ Type: resource.CredentialLibrary, Subtype: UnknownSubtype, }, + VaultLdapCredentialLibraryPrefix: { + Type: resource.CredentialLibrary, + Subtype: UnknownSubtype, + }, VaultDynamicCredentialPrefix: { Type: resource.Credential, Subtype: UnknownSubtype, diff --git a/go.mod b/go.mod index 3cedea874..a424d78df 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/fatih/structs v1.1.0 github.com/favadi/protoc-go-inject-tag v1.4.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-ldap/ldap/v3 v3.4.11 github.com/golang-migrate/migrate/v4 v4.19.0 github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 github.com/golang/protobuf v1.5.4 @@ -139,10 +140,9 @@ require ( github.com/dvsekhvalnov/jose2go v1.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect - github.com/go-ldap/ldap/v3 v3.4.8 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index c7955d18d..3b17bd112 100644 --- a/go.sum +++ b/go.sum @@ -128,16 +128,15 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= -github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= -github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= +github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= +github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -184,8 +183,6 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -494,8 +491,6 @@ github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -504,10 +499,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= @@ -577,9 +569,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -603,7 +593,6 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -612,10 +601,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -656,7 +642,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= @@ -667,7 +652,6 @@ golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/credential/service_list_libraries_ext_test.go b/internal/credential/service_list_libraries_ext_test.go index 82dfb2f22..034dcd9df 100644 --- a/internal/credential/service_list_libraries_ext_test.go +++ b/internal/credential/service_list_libraries_ext_test.go @@ -48,8 +48,10 @@ func TestLibraryService_List(t *testing.T) { vault.TestCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), globals.UnspecifiedCredentialType, 1)[0], vault.TestSSHCertificateCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), 1)[0], vault.TestCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), globals.UnspecifiedCredentialType, 1)[0], + vault.TestLdapCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), 1)[0], vault.TestSSHCertificateCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), 1)[0], vault.TestSSHCertificateCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), 1)[0], + vault.TestLdapCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), 1)[0], } // since we sort by create time descending, we need to reverse the slice @@ -66,7 +68,9 @@ func TestLibraryService_List(t *testing.T) { cmpopts.IgnoreUnexported( vault.CredentialLibrary{}, vault.SSHCertificateCredentialLibrary{}, + vault.LdapCredentialLibrary{}, store.SSHCertificateCredentialLibrary{}, + store.LdapCredentialLibrary{}, store.CredentialLibrary{}, timestamp.Timestamp{}, timestamppb.Timestamp{}, @@ -385,7 +389,7 @@ func TestLibraryService_List(t *testing.T) { require.NotNil(t, resp.ListToken) require.Equal(t, resp.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp.CompleteListing) - require.Equal(t, resp.EstimatedItemCount, 5) + require.Equal(t, resp.EstimatedItemCount, 7) require.Empty(t, resp.DeletedIds) require.Len(t, resp.Items, 1) require.Empty(t, cmp.Diff(resp.Items[0], libs[0], cmpOpts...)) @@ -394,7 +398,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp2.CompleteListing) - require.Equal(t, resp2.EstimatedItemCount, 5) + require.Equal(t, resp2.EstimatedItemCount, 7) require.Empty(t, resp2.DeletedIds) require.Len(t, resp2.Items, 1) require.Empty(t, cmp.Diff(resp2.Items[0], libs[1], cmpOpts...)) @@ -403,7 +407,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp3.CompleteListing) - require.Equal(t, resp3.EstimatedItemCount, 5) + require.Equal(t, resp3.EstimatedItemCount, 7) require.Empty(t, resp3.DeletedIds) require.Len(t, resp3.Items, 1) require.Empty(t, cmp.Diff(resp3.Items[0], libs[2], cmpOpts...)) @@ -412,7 +416,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp4.CompleteListing) - require.Equal(t, resp4.EstimatedItemCount, 5) + require.Equal(t, resp4.EstimatedItemCount, 7) require.Empty(t, resp4.DeletedIds) require.Len(t, resp4.Items, 1) require.Empty(t, cmp.Diff(resp4.Items[0], libs[3], cmpOpts...)) @@ -420,21 +424,39 @@ func TestLibraryService_List(t *testing.T) { resp5, err := credential.ListLibrariesPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, credStore.GetPublicId()) require.NoError(t, err) require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) - require.True(t, resp5.CompleteListing) - require.Equal(t, resp5.EstimatedItemCount, 5) + require.False(t, resp5.CompleteListing) + require.Equal(t, resp5.EstimatedItemCount, 7) require.Empty(t, resp5.DeletedIds) require.Len(t, resp5.Items, 1) require.Empty(t, cmp.Diff(resp5.Items[0], libs[4], cmpOpts...)) - // Finished initial pagination phase, request refresh - // Expect no results. - resp6, err := credential.ListLibrariesRefresh(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, credStore.GetPublicId()) + resp6, err := credential.ListLibrariesPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, credStore.GetPublicId()) require.NoError(t, err) require.Equal(t, resp6.ListToken.GrantsHash, []byte("some hash")) - require.True(t, resp6.CompleteListing) - require.Equal(t, resp6.EstimatedItemCount, 5) + require.False(t, resp6.CompleteListing) + require.Equal(t, resp6.EstimatedItemCount, 7) require.Empty(t, resp6.DeletedIds) - require.Empty(t, resp6.Items) + require.Len(t, resp6.Items, 1) + require.Empty(t, cmp.Diff(resp6.Items[0], libs[5], cmpOpts...)) + + resp7, err := credential.ListLibrariesPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, credStore.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp7.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp7.CompleteListing) + require.Equal(t, resp7.EstimatedItemCount, 7) + require.Empty(t, resp7.DeletedIds) + require.Len(t, resp7.Items, 1) + require.Empty(t, cmp.Diff(resp7.Items[0], libs[6], cmpOpts...)) + + // Finished initial pagination phase, request refresh + // Expect no results. + resp8, err := credential.ListLibrariesRefresh(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, credStore.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp8.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp8.CompleteListing) + require.Equal(t, resp8.EstimatedItemCount, 7) + require.Empty(t, resp8.DeletedIds) + require.Empty(t, resp8.Items) // Create some new libraries newLib1 := vault.TestCredentialLibraries(t, conn, wrapper, credStore.GetPublicId(), globals.UnspecifiedCredentialType, 1)[0] @@ -453,33 +475,33 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) // Refresh again, should get newLib2 - resp7, err := credential.ListLibrariesRefresh(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, credStore.GetPublicId()) - require.NoError(t, err) - require.Equal(t, resp7.ListToken.GrantsHash, []byte("some hash")) - require.False(t, resp7.CompleteListing) - require.Equal(t, resp7.EstimatedItemCount, 7) - require.Empty(t, resp7.DeletedIds) - require.Len(t, resp7.Items, 1) - require.Empty(t, cmp.Diff(resp7.Items[0], newLib2, cmpOpts...)) - - // Refresh again, should get newLib1 - resp8, err := credential.ListLibrariesRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, credStore.GetPublicId()) - require.NoError(t, err) - require.Equal(t, resp8.ListToken.GrantsHash, []byte("some hash")) - require.True(t, resp8.CompleteListing) - require.Equal(t, resp8.EstimatedItemCount, 7) - require.Empty(t, resp8.DeletedIds) - require.Len(t, resp8.Items, 1) - require.Empty(t, cmp.Diff(resp8.Items[0], newLib1, cmpOpts...)) - - // Refresh again, should get no results - resp9, err := credential.ListLibrariesRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, credStore.GetPublicId()) + resp9, err := credential.ListLibrariesRefresh(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, credStore.GetPublicId()) require.NoError(t, err) require.Equal(t, resp9.ListToken.GrantsHash, []byte("some hash")) - require.True(t, resp9.CompleteListing) - require.Equal(t, resp9.EstimatedItemCount, 7) + require.False(t, resp9.CompleteListing) + require.Equal(t, resp9.EstimatedItemCount, 9) require.Empty(t, resp9.DeletedIds) - require.Empty(t, resp9.Items) + require.Len(t, resp9.Items, 1) + require.Empty(t, cmp.Diff(resp9.Items[0], newLib2, cmpOpts...)) + + // Refresh again, should get newLib1 + resp10, err := credential.ListLibrariesRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, credStore.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp10.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp10.CompleteListing) + require.Equal(t, resp10.EstimatedItemCount, 9) + require.Empty(t, resp10.DeletedIds) + require.Len(t, resp10.Items, 1) + require.Empty(t, cmp.Diff(resp10.Items[0], newLib1, cmpOpts...)) + + // Refresh again, should get no results + resp11, err := credential.ListLibrariesRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, credStore.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp11.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp11.CompleteListing) + require.Equal(t, resp11.EstimatedItemCount, 9) + require.Empty(t, resp11.DeletedIds) + require.Empty(t, resp11.Items) }) t.Run("simple pagination with aggressive filtering", func(t *testing.T) { @@ -492,7 +514,7 @@ func TestLibraryService_List(t *testing.T) { require.NotNil(t, resp.ListToken) require.Equal(t, resp.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp.CompleteListing) - require.Equal(t, resp.EstimatedItemCount, 5) + require.Equal(t, resp.EstimatedItemCount, 7) require.Empty(t, resp.DeletedIds) require.Len(t, resp.Items, 1) require.Empty(t, cmp.Diff(resp.Items[0], libs[1], cmpOpts...)) @@ -502,7 +524,7 @@ func TestLibraryService_List(t *testing.T) { require.NotNil(t, resp2.ListToken) require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) require.True(t, resp2.CompleteListing) - require.Equal(t, resp2.EstimatedItemCount, 5) + require.Equal(t, resp2.EstimatedItemCount, 7) require.Empty(t, resp2.DeletedIds) require.Len(t, resp2.Items, 1) require.Empty(t, cmp.Diff(resp2.Items[0], libs[len(libs)-1], cmpOpts...)) @@ -512,7 +534,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) require.True(t, resp3.CompleteListing) - require.Equal(t, resp3.EstimatedItemCount, 5) + require.Equal(t, resp3.EstimatedItemCount, 7) require.Empty(t, resp3.DeletedIds) require.Empty(t, resp3.Items) @@ -545,7 +567,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp4.CompleteListing) - require.Equal(t, resp4.EstimatedItemCount, 9) + require.Equal(t, resp4.EstimatedItemCount, 11) require.Empty(t, resp4.DeletedIds) require.Len(t, resp4.Items, 1) require.Empty(t, cmp.Diff(resp4.Items[0], newLibs2[0], cmpOpts...)) @@ -555,7 +577,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) require.True(t, resp5.CompleteListing) - require.Equal(t, resp5.EstimatedItemCount, 9) + require.Equal(t, resp5.EstimatedItemCount, 11) require.Empty(t, resp5.DeletedIds) require.Len(t, resp5.Items, 1) require.Empty(t, cmp.Diff(resp5.Items[0], newLibs1[0], cmpOpts...)) @@ -566,7 +588,7 @@ func TestLibraryService_List(t *testing.T) { return true, nil } deletedCredentialLibraryId := libs[0].GetPublicId() - _, err := repo.DeleteSSHCertificateCredentialLibrary(ctx, prj.GetPublicId(), deletedCredentialLibraryId) + _, err := repo.DeleteLdapCredentialLibrary(ctx, prj.GetPublicId(), deletedCredentialLibraryId) require.NoError(t, err) libs = libs[1:] @@ -579,19 +601,19 @@ func TestLibraryService_List(t *testing.T) { require.NotNil(t, resp.ListToken) require.Equal(t, resp.ListToken.GrantsHash, []byte("some hash")) require.False(t, resp.CompleteListing) - require.Equal(t, resp.EstimatedItemCount, 4) + require.Equal(t, resp.EstimatedItemCount, 6) require.Empty(t, resp.DeletedIds) require.Len(t, resp.Items, 1) require.Empty(t, cmp.Diff(resp.Items[0], libs[0], cmpOpts...)) // request remaining results - resp2, err := credential.ListLibrariesPage(ctx, []byte("some hash"), 3, filterFunc, resp.ListToken, repo, credStore.GetPublicId()) + resp2, err := credential.ListLibrariesPage(ctx, []byte("some hash"), 5, filterFunc, resp.ListToken, repo, credStore.GetPublicId()) require.NoError(t, err) require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) require.True(t, resp2.CompleteListing) - require.Equal(t, resp2.EstimatedItemCount, 4) + require.Equal(t, resp2.EstimatedItemCount, 6) require.Empty(t, resp2.DeletedIds) - require.Len(t, resp2.Items, 3) + require.Len(t, resp2.Items, 5) require.Empty(t, cmp.Diff(resp2.Items, libs[1:], cmpOpts...)) deletedCredentialLibraryId = libs[0].GetPublicId() @@ -608,7 +630,7 @@ func TestLibraryService_List(t *testing.T) { require.NoError(t, err) require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) require.True(t, resp3.CompleteListing) - require.Equal(t, resp3.EstimatedItemCount, 3) + require.Equal(t, resp3.EstimatedItemCount, 5) require.Contains(t, resp3.DeletedIds, deletedCredentialLibraryId) require.Empty(t, resp3.Items) }) diff --git a/internal/credential/vault/credential_library.go b/internal/credential/vault/credential_library.go index 3b49dd6f1..0294f1ceb 100644 --- a/internal/credential/vault/credential_library.go +++ b/internal/credential/vault/credential_library.go @@ -206,6 +206,20 @@ func (l *listCredentialLibraryResult) toLibrary(ctx context.Context) (credential AdditionalValidPrincipals: l.AdditionalValidPrincipals, }, }, nil + case "ldap": + return &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + PublicId: l.PublicId, + StoreId: l.StoreId, + Name: l.Name, + Description: l.Description, + CreateTime: l.CreateTime, + UpdateTime: l.UpdateTime, + Version: uint32(l.Version), + VaultPath: l.VaultPath, + CredentialType: l.CredentialType, + }, + }, nil default: return nil, errors.New(ctx, errors.Internal, op, fmt.Sprintf("unexpected vault credential library type %s returned", l.Type)) } diff --git a/internal/credential/vault/docker.go b/internal/credential/vault/docker.go index fe8686291..7d7f9a108 100644 --- a/internal/credential/vault/docker.go +++ b/internal/credential/vault/docker.go @@ -6,8 +6,9 @@ package vault import "testing" var ( - newVaultServer func(t testing.TB, opt ...TestOption) *TestVaultServer = skipNewServer - mountDatabase func(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestDatabase = skipMountDatabase + newVaultServer func(t testing.TB, opt ...TestOption) *TestVaultServer = skipNewServer + mountLdapServer func(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestLdapServer = skipMountLdapServer + mountDatabase func(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestDatabase = skipMountDatabase ) func skipNewServer(t testing.TB, opt ...TestOption) *TestVaultServer { @@ -19,3 +20,8 @@ func skipMountDatabase(t testing.TB, v *TestVaultServer, opt ...TestOption) *Tes t.Skip("docker not available") return nil } + +func skipMountLdapServer(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestLdapServer { + t.Skip("docker not available") + return nil +} diff --git a/internal/credential/vault/ldap_credential_library.go b/internal/credential/vault/ldap_credential_library.go new file mode 100644 index 000000000..03de8b99e --- /dev/null +++ b/internal/credential/vault/ldap_credential_library.go @@ -0,0 +1,105 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package vault + +import ( + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/credential" + "github.com/hashicorp/boundary/internal/credential/vault/store" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/oplog" + "github.com/hashicorp/boundary/internal/types/resource" + "google.golang.org/protobuf/proto" +) + +var _ credential.Library = (*LdapCredentialLibrary)(nil) + +// LdapCredentialLibrary is a credential library that issues +// ldap credentials using the vault ldap secret engine. +type LdapCredentialLibrary struct { + *store.LdapCredentialLibrary + tableName string `gorm:"-"` +} + +// NewLdapCredentialLibrary creates a new in memory LdapCredentialLibrary +// for a Vault backend at vaultPath assigned to storeId. +// Name and description are the only valid options. All other options are ignored. +func NewLdapCredentialLibrary(storeId string, vaultPath string, opt ...Option) (*LdapCredentialLibrary, error) { + const op = "vault.NewLdapCredentialLibrary" + opts := getOpts(opt...) + + l := &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: storeId, + Name: opts.withName, + Description: opts.withDescription, + VaultPath: vaultPath, + CredentialType: string(globals.UsernamePasswordDomainCredentialType), + }, + } + + return l, nil +} + +func allocLdapCredentialLibrary() *LdapCredentialLibrary { + return &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{}, + } +} + +func (l *LdapCredentialLibrary) clone() *LdapCredentialLibrary { + cp := proto.Clone(l.LdapCredentialLibrary) + return &LdapCredentialLibrary{ + LdapCredentialLibrary: cp.(*store.LdapCredentialLibrary), + } +} + +func (l *LdapCredentialLibrary) setId(i string) { + l.PublicId = i +} + +// TableName returns the table name. +func (l *LdapCredentialLibrary) TableName() string { + if l.tableName != "" { + return l.tableName + } + return "credential_vault_ldap_library" +} + +// SetTableName sets the table name. +func (l *LdapCredentialLibrary) SetTableName(n string) { + l.tableName = n +} + +// GetResourceType returns the resource type of the CredentialLibrary +func (l *LdapCredentialLibrary) GetResourceType() resource.Type { + return resource.CredentialLibrary +} + +func (l *LdapCredentialLibrary) oplog(op oplog.OpType) oplog.Metadata { + metadata := oplog.Metadata{ + "resource-public-id": []string{l.PublicId}, + "resource-type": []string{"credential-vault-ldap-library"}, + "op-type": []string{op.String()}, + } + if l.StoreId != "" { + metadata["store-id"] = []string{l.StoreId} + } + return metadata +} + +// CredentialType returns the type of credential the library retrieves. +func (l *LdapCredentialLibrary) CredentialType() globals.CredentialType { + return globals.CredentialType(l.LdapCredentialLibrary.CredentialType) +} + +type deletedLdapCredentialLibrary struct { + PublicId string `gorm:"primary_key"` + DeleteTime *timestamp.Timestamp +} + +// TableName returns the tablename to override the default gorm table name +func (s *deletedLdapCredentialLibrary) TableName() string { + return "credential_vault_ldap_library_deleted" +} diff --git a/internal/credential/vault/private_library.go b/internal/credential/vault/private_library.go index 5c1fd0a28..5a00b1a48 100644 --- a/internal/credential/vault/private_library.go +++ b/internal/credential/vault/private_library.go @@ -20,6 +20,7 @@ import ( "strings" "time" + ldapv3 "github.com/go-ldap/ldap/v3" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/credential" "github.com/hashicorp/boundary/internal/credential/vault/internal/sshprivatekey" @@ -684,6 +685,42 @@ func (pl *privateCredentialLibraryAllTypes) toTypedIssuingCredentialLibrary() is Extensions: pl.Extensions, AdditionalValidPrincipals: pl.AdditionalValidPrincipals, } + case "ldap": + return &ldapIssuingCredentialLibrary{ + PublicId: pl.PublicId, + StoreId: pl.StoreId, + CredType: pl.CredType, + Username: pl.Username, + Name: pl.Name, + Description: pl.Description, + CreateTime: pl.CreateTime, + UpdateTime: pl.UpdateTime, + Version: pl.Version, + ProjectId: pl.ProjectId, + VaultPath: pl.VaultPath, + VaultAddress: pl.VaultAddress, + Namespace: pl.Namespace, + CaCert: pl.CaCert, + TlsServerName: pl.TlsServerName, + TlsSkipVerify: pl.TlsSkipVerify, + WorkerFilter: pl.WorkerFilter, + TokenHmac: pl.TokenHmac, + Token: pl.Token, + CtToken: pl.CtToken, + TokenKeyId: pl.TokenKeyId, + ClientCert: pl.ClientCert, + ClientKey: pl.ClientKey, + CtClientKey: pl.CtClientKey, + ClientKeyId: pl.ClientKeyId, + Purpose: pl.Purpose, + KeyType: pl.KeyType, + KeyBits: pl.KeyBits, + Ttl: pl.Ttl, + KeyId: pl.KeyId, + CriticalOptions: pl.CriticalOptions, + Extensions: pl.Extensions, + AdditionalValidPrincipals: pl.AdditionalValidPrincipals, + } default: return &genericIssuingCredentialLibrary{ PublicId: pl.PublicId, @@ -1110,3 +1147,232 @@ func (lib *sshCertIssuingCredentialLibrary) retrieveCredential(ctx context.Conte certificate: []byte(cert), }, nil } + +type ldapIssuingCredentialLibrary struct { + PublicId string + StoreId string + Name string + Description string + CreateTime *timestamp.Timestamp + UpdateTime *timestamp.Timestamp + Version uint32 + VaultPath string + CredType string + ProjectId string + VaultAddress string + Namespace string + CaCert []byte + TlsServerName string + TlsSkipVerify bool + WorkerFilter string + Token TokenSecret + CtToken []byte + TokenHmac []byte + TokenKeyId string + ClientCert []byte + ClientKey KeySecret + CtClientKey []byte + ClientKeyId string + Username string + KeyType string + KeyBits int + KeyId string + Ttl string + CriticalOptions []byte + Extensions []byte + Purpose credential.Purpose + AdditionalValidPrincipals string +} + +// GetPublicId returns the credential library's id. +func (lib *ldapIssuingCredentialLibrary) GetPublicId() string { return lib.PublicId } + +// GetStoreId returns the credential library's credential store id. +func (lib *ldapIssuingCredentialLibrary) GetStoreId() string { return lib.StoreId } + +// GetName returns the credential library's name. +func (lib *ldapIssuingCredentialLibrary) GetName() string { return lib.Name } + +// GetDescription returns the credential library's description. +func (lib *ldapIssuingCredentialLibrary) GetDescription() string { return lib.Description } + +// GetVersion returns the credential library's version. +func (lib *ldapIssuingCredentialLibrary) GetVersion() uint32 { return lib.Version } + +// GetPurpose returns the credential library's purpose. +func (lib *ldapIssuingCredentialLibrary) GetPurpose() credential.Purpose { return lib.Purpose } + +// GetCreateTime returns the credential library's create time. +func (lib *ldapIssuingCredentialLibrary) GetCreateTime() *timestamp.Timestamp { return lib.CreateTime } + +// GetUpdateTime returns the credential library's update time. +func (lib *ldapIssuingCredentialLibrary) GetUpdateTime() *timestamp.Timestamp { return lib.UpdateTime } + +// CredentialType returns the credential library's issuing credential type. +func (lib *ldapIssuingCredentialLibrary) CredentialType() globals.CredentialType { + switch ct := lib.CredType; ct { + case "": + return globals.UnspecifiedCredentialType + default: + return globals.CredentialType(ct) + } +} + +// GetResourceType returns the credential library's resource type. +func (lib *ldapIssuingCredentialLibrary) GetResourceType() resource.Type { + return resource.CredentialLibrary +} + +// client produces a Vault API client using this credential library's data. +func (lib *ldapIssuingCredentialLibrary) client(ctx context.Context) (vaultClient, error) { + const op = "vault.(genericIssuingCredentialLibrary).client" + clientConfig := &clientConfig{ + Addr: lib.VaultAddress, + Token: lib.Token, + CaCert: lib.CaCert, + TlsServerName: lib.TlsServerName, + TlsSkipVerify: lib.TlsSkipVerify, + Namespace: lib.Namespace, + } + + if lib.ClientKey != nil { + clientConfig.ClientCert = lib.ClientCert + clientConfig.ClientKey = lib.ClientKey + } + + client, err := vaultClientFactoryFn(ctx, clientConfig, WithWorkerFilter(lib.WorkerFilter)) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to create vault client")) + } + return client, nil +} + +var vaultLdapPathRegexp = regexp.MustCompile(`^.+\/(static-cred|creds).+$`) + +// retrieveCredential retrieves a dynamic LDAP credential from Vault for a +// specific session and connection. +func (lib *ldapIssuingCredentialLibrary) retrieveCredential(ctx context.Context, op errors.Op, _ ...credential.Option) (dynamicCred, error) { + credId, err := newCredentialId(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + client, err := lib.client(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + var secret *vault.Secret + + secret, err = client.get(ctx, lib.VaultPath) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if secret == nil { + return nil, errors.E(ctx, errors.WithCode(errors.VaultEmptySecret), errors.WithOp(op)) + } + + leaseDuration := time.Duration(secret.LeaseDuration) * time.Second + cred, err := newCredential(ctx, lib.GetPublicId(), secret.LeaseID, lib.TokenHmac, leaseDuration) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + cred.PublicId = credId + cred.IsRenewable = secret.Renewable + + upd := &usrPassDomainCred{ + baseCred: &baseCred{ + Credential: cred, + lib: lib, + secretData: secret.Data, + }, + } + + username, ok := secret.Data["username"].(string) + if !ok { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret did not contain a username or response was not in the expected format") + } + upd.username = username + + pwData, ok := secret.Data["password"].(string) + if !ok { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret did not contain a password or response was not in the expected format") + } + upd.password = credential.Password(pwData) + + matches := vaultLdapPathRegexp.FindStringSubmatch(lib.VaultPath) + if len(matches) < 2 { // [0] is the vault path, [1] is static-cred or creds, if it exists. + return nil, errors.New(ctx, errors.InvalidParameter, op, "vault path was not in an expected format. expected path containing \"static-cred\" or \"creds\"") + } + switch matches[1] { + case "static-cred": + dn, ok := secret.Data["dn"].(string) + if !ok { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret did not contain a dn or response was not in the expected format") + } + domain, err := extractDomainFromDn(dn) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + upd.domain = domain + case "creds": + var dns []string + dis, ok := secret.Data["distinguished_names"].([]any) + if ok { + for _, value := range dis { + if v, ok := value.(string); ok { + dns = append(dns, v) + } else { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret returned distinguished_names with non-string member") + } + } + } else { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret did not contain distinguished_names or response was not in the expected format") + } + if len(dns) == 0 { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret returned empty distinguished_names") + } + + // In the Vault LDAP secrets engine, no deduplication occurs and order + // is maintained from the LDIF statements, so the assumption here is + // that the first DN should correspond to the user's creation in the + // creation LDIF, which should correspond to the correct domain. + // https://developer.hashicorp.com/vault/docs/secrets/ldap#dynamic-credentials + domain, err := extractDomainFromDn(dns[0]) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + upd.domain = domain + } + + return upd, nil +} + +// extractDomainFromDn composes a domain name from all the "dc" attributes in an +// LDAP DN. +func extractDomainFromDn(dnStr string) (string, error) { + if dnStr == "" { + return "", fmt.Errorf("empty DN") + } + + dn, err := ldapv3.ParseDN(dnStr) + if err != nil { + return "", fmt.Errorf("failed to parse DN: %w", err) + } + + var dcs []string + for _, rdn := range dn.RDNs { + for _, attr := range rdn.Attributes { + // "0.9.2342.19200300.100.1.25" is the OID for the "dc" attribute + // and it is a valid representation in a DN string. + // (https://docs.ldap.com/specs/rfc4519.txt) + if strings.EqualFold(attr.Type, "dc") || strings.EqualFold(attr.Type, "0.9.2342.19200300.100.1.25") { + dcs = append(dcs, attr.Value) + } + } + } + if len(dcs) == 0 { + return "", fmt.Errorf("no domain component (dc) attribute found in dn") + } + + return strings.Join(dcs, "."), nil +} diff --git a/internal/credential/vault/private_library_test.go b/internal/credential/vault/private_library_test.go index b151df753..a57d4e2ab 100644 --- a/internal/credential/vault/private_library_test.go +++ b/internal/credential/vault/private_library_test.go @@ -1879,6 +1879,136 @@ func TestBaseToSshPriKey(t *testing.T) { } } +func TestRepository_ldapCertIssuingCredentialLibrary_retrieveCredential(t *testing.T) { + // Create Vault server + v := NewTestVaultServer(t, WithTestVaultTLS(TestNoTLS), WithVaultVersion("1.20.0"), WithDockerNetwork(true)) + require.NotNil(t, v) + + vc := v.client(t).cl + mounts, err := vc.Sys().ListMounts() + require.NoError(t, err) + require.NotEmpty(t, mounts) + + // Create OpenLDAP server and mount Vault LDAP secrets engine. + _ = MountLdapServer(t, v) + + // Create and setup Boundary DB + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + sche := scheduler.TestScheduler(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + rw := db.New(conn) + repo, err := NewRepository(t.Context(), rw, rw, kms, sche) + require.NoError(t, err) + require.NotNil(t, repo) + + sec, token := v.CreateToken(t, WithPolicies([]string{"default", "boundary-controller", "ldap"}), WithTokenPeriod(time.Hour)) + require.NotNil(t, sec) + require.NotEmpty(t, token) + + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + cs := TestCredentialStore(t, conn, wrapper, prj.GetPublicId(), v.Addr, token, sec.Auth.Accessor) + + tests := []struct { + name string + vaultPath string + expStaticUsername string + expDomain string + expDynamicCred bool + expErr bool + expErrMsg string + }{ + { + name: "doesntExistStaticCred", + vaultPath: "ldap/static-cred/doesntexist", + expErr: true, + expErrMsg: "unknown role:", + }, + { + name: "doesntExistDynamicCred", + vaultPath: "ldap/creds/doesntexist", + expErr: true, + expErrMsg: "vault secret is empty", + }, + { + name: "staticCred", + vaultPath: "ldap/static-cred/einstein", + expStaticUsername: "einstein", + expDomain: "example.org", + }, + { + name: "staticCredHierarchical", + vaultPath: "ldap/static-cred/myorg/myproject/newton", + expStaticUsername: "newton", + expDomain: "example.org", + }, + { + name: "dynamicCred", + vaultPath: "ldap/creds/scientists", + expDynamicCred: true, + expDomain: "example.org", + }, + { + name: "dynamicCredHierarchical", + vaultPath: "ldap/creds/myorg/myproject/scientists", + expDynamicCred: true, + expDomain: "example.org", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lib, err := NewLdapCredentialLibrary(cs.GetPublicId(), tt.vaultPath) + require.NoError(t, err) + require.NotNil(t, lib) + lib.PublicId, err = newLdapCredentialLibraryId(t.Context()) + require.NoError(t, err) + + _, err = rw.DoTx(t.Context(), db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, iw db.Writer) error { + return iw.Create(t.Context(), lib) + }, + ) + require.NoError(t, err) + + libs, err := repo.getIssueCredLibraries(t.Context(), []credential.Request{{ + SourceId: lib.GetPublicId(), + }}) + require.NoError(t, err) + require.Len(t, libs, 1) + + cred, err := libs[0].retrieveCredential(t.Context(), "TestLdapRetrieveCredential") + if tt.expErr { + require.ErrorContains(t, err, tt.expErrMsg) + return + } + require.NoError(t, err) + + require.IsType(t, &usrPassDomainCred{}, cred) + credUpd := cred.(*usrPassDomainCred) + require.NotEmpty(t, credUpd.baseCred) + require.NotEmpty(t, credUpd.getCredential()) + require.EqualValues(t, libs[0], credUpd.lib) + require.NotEmpty(t, credUpd.secretData) + require.Contains(t, credUpd.secretData, "username") + require.Contains(t, credUpd.secretData, "password") + + require.EqualValues(t, credUpd.secretData["password"], credUpd.Password()) + require.EqualValues(t, tt.expDomain, credUpd.Domain()) + if !tt.expDynamicCred { + require.EqualValues(t, credUpd.secretData["username"], credUpd.Username()) + require.EqualValues(t, tt.expStaticUsername, credUpd.Username()) + require.False(t, credUpd.GetIsRenewable()) + } else { + require.EqualValues(t, credUpd.secretData["username"], credUpd.Username()) + require.Contains(t, credUpd.Username(), "b_token-TestRepository-ldapCertIssuingCredentialLibrary-retrieveCredential_") + require.True(t, credUpd.GetIsRenewable()) + } + }) + } +} + func TestRepository_sshCertIssuingCredentialLibrary_retrieveCredential(t *testing.T) { t.Parallel() @@ -2093,3 +2223,83 @@ func TestRepository_sshCertIssuingCredentialLibrary_retrieveCredential(t *testin }) } } + +func TestExtractDomainFromDn(t *testing.T) { + tests := []struct { + name string + inDn string + expDomain string + expErr bool + expErrMsgContains string + }{ + { + name: "emptyString", + inDn: "", + expErr: true, + expErrMsgContains: "empty DN", + }, + { + name: "notADn", + inDn: "a string that isn't really a DN", + expErr: true, + expErrMsgContains: "failed to parse DN:", + }, + { + name: "validDn", + inDn: "dc=domain,dc=tld", + expDomain: "domain.tld", + }, + { + name: "validDnSpaces", + inDn: "dc = domain , dc = tld", + expDomain: "domain.tld", + }, + { + name: "validDnCaps", + inDn: "DC=DOMAIN,DC=TLD", + expDomain: "DOMAIN.TLD", + }, + { + name: "validDnOid", + inDn: "0.9.2342.19200300.100.1.25=Domain,0.9.2342.19200300.100.1.25=Tld", + expDomain: "Domain.Tld", + }, + { + name: "validDnOidMixed", + inDn: "dc=sub1,0.9.2342.19200300.100.1.25=Domain,dc=Tld", + expDomain: "sub1.Domain.Tld", + }, + { + name: "validDnWithCn", + inDn: "CN=My User,CN=admin,DC=mycorp,DC=domain,DC=TLD", + expDomain: "mycorp.domain.TLD", + }, + { + name: "validDnWithEscapeChars", + inDn: `CN=mycn,OU=HashiCorp\, an IBM Company,DC=domain,DC=TLD`, + expDomain: "domain.TLD", + }, + { + name: "validDnWithEscapeChars2", + inDn: `CN=Before\0DAfter,OU=HashiCorp,DC=domain,DC=TLD`, + expDomain: "domain.TLD", + }, + { + name: "validDnWithSubdomains", + inDn: `dc=foo,dc=bAR,dc=BAZ,dc=domain,dc=Tld`, + expDomain: "foo.bAR.BAZ.domain.Tld", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + domain, err := extractDomainFromDn(tt.inDn) + if tt.expErr { + require.ErrorContains(t, err, tt.expErrMsgContains) + require.Empty(t, domain) + return + } + require.NoError(t, err) + require.Equal(t, tt.expDomain, domain) + }) + } +} diff --git a/internal/credential/vault/public_ids.go b/internal/credential/vault/public_ids.go index cdea3f7ae..1a4b7f12d 100644 --- a/internal/credential/vault/public_ids.go +++ b/internal/credential/vault/public_ids.go @@ -18,6 +18,7 @@ func init() { globals.RegisterPrefixToResourceInfo(globals.VaultDynamicCredentialPrefix, resource.Credential, credential.Domain, Subtype) globals.RegisterPrefixToResourceInfo(globals.VaultCredentialLibraryPrefix, resource.CredentialLibrary, credential.Domain, GenericLibrarySubtype) globals.RegisterPrefixToResourceInfo(globals.VaultSshCertificateCredentialLibraryPrefix, resource.CredentialLibrary, credential.Domain, SSHCertificateLibrarySubtype) + globals.RegisterPrefixToResourceInfo(globals.VaultLdapCredentialLibraryPrefix, resource.CredentialLibrary, credential.Domain, LdapCredentialLibrarySubtype) } // PublicId prefixes for the resources in the vault package. @@ -25,6 +26,7 @@ const ( Subtype = globals.Subtype("vault") GenericLibrarySubtype = globals.Subtype("vault-generic") SSHCertificateLibrarySubtype = globals.Subtype("vault-ssh-certificate") + LdapCredentialLibrarySubtype = globals.Subtype("vault-ldap") ) func newCredentialStoreId(ctx context.Context) (string, error) { @@ -58,3 +60,11 @@ func newSSHCertificateCredentialLibraryId(ctx context.Context) (string, error) { } return id, nil } + +func newLdapCredentialLibraryId(ctx context.Context) (string, error) { + id, err := db.NewPublicId(ctx, globals.VaultLdapCredentialLibraryPrefix) + if err != nil { + return "", errors.Wrap(ctx, err, "vault.newLdapCredentialLibraryPrefix") + } + return id, nil +} diff --git a/internal/credential/vault/query.go b/internal/credential/vault/query.go index de275a252..9fd09eef4 100644 --- a/internal/credential/vault/query.go +++ b/internal/credential/vault/query.go @@ -195,7 +195,8 @@ select sum(reltuples::bigint) as estimate from pg_class where oid in ( 'credential_vault_generic_library'::regclass, - 'credential_vault_ssh_cert_library'::regclass + 'credential_vault_ssh_cert_library'::regclass, + 'credential_vault_ldap_library'::regclass ) ` @@ -217,6 +218,11 @@ ssh_cert_libs as ( from credential_vault_ssh_cert_library where public_id in (select public_id from libraries) ), +ldap_libs as ( + select * + from credential_vault_ldap_library + where public_id in (select public_id from libraries) +), final as ( select public_id, store_id, @@ -263,6 +269,29 @@ final as ( additional_valid_principals, 'ssh' as type from ssh_cert_libs + union + select public_id, + store_id, + project_id, + name, + description, + create_time, + update_time, + version, + vault_path, + credential_type, + null as http_method, -- Add to make union uniform + null as http_request_body, -- Add to make union uniform + null as username, -- Add to make union uniform + null as key_type, -- Add to make union uniform + null as key_bits, -- Add to make union uniform + null as ttl, -- Add to make union uniform + null as key_id, -- Add to make union uniform + null as critical_options, -- Add to make union uniform + null as extensions, -- Add to make union uniform + null as additional_valid_principals, -- Add to make union uniform + 'ldap' as type + from ldap_libs ) select * from final @@ -288,6 +317,11 @@ ssh_cert_libs as ( from credential_vault_ssh_cert_library where public_id in (select public_id from libraries) ), +ldap_libs as ( + select * + from credential_vault_ldap_library + where public_id in (select public_id from libraries) +), final as ( select public_id, store_id, @@ -334,6 +368,29 @@ final as ( additional_valid_principals, 'ssh' as type from ssh_cert_libs + union + select public_id, + store_id, + project_id, + name, + description, + create_time, + update_time, + version, + vault_path, + credential_type, + null as http_method, -- Add to make union uniform + null as http_request_body, -- Add to make union uniform + null as username, -- Add to make union uniform + null as key_type, -- Add to make union uniform + null as key_bits, -- Add to make union uniform + null as ttl, -- Add to make union uniform + null as key_id, -- Add to make union uniform + null as critical_options, -- Add to make union uniform + null as extensions, -- Add to make union uniform + null as additional_valid_principals, -- Add to make union uniform + 'ldap' as type + from ldap_libs ) select * from final @@ -359,6 +416,11 @@ ssh_cert_libs as ( from credential_vault_ssh_cert_library where public_id in (select public_id from libraries) ), +ldap_libs as ( + select * + from credential_vault_ldap_library + where public_id in (select public_id from libraries) +), final as ( select public_id, store_id, @@ -405,6 +467,29 @@ final as ( additional_valid_principals, 'ssh' as type from ssh_cert_libs + union + select public_id, + store_id, + project_id, + name, + description, + create_time, + update_time, + version, + vault_path, + credential_type, + null as http_method, -- Add to make union uniform + null as http_request_body, -- Add to make union uniform + null as username, -- Add to make union uniform + null as key_type, -- Add to make union uniform + null as key_bits, -- Add to make union uniform + null as ttl, -- Add to make union uniform + null as key_id, -- Add to make union uniform + null as critical_options, -- Add to make union uniform + null as extensions, -- Add to make union uniform + null as additional_valid_principals, -- Add to make union uniform + 'ldap' as type + from ldap_libs ) select * from final @@ -431,6 +516,11 @@ ssh_cert_libs as ( from credential_vault_ssh_cert_library where public_id in (select public_id from libraries) ), +ldap_libs as ( + select * + from credential_vault_ldap_library + where public_id in (select public_id from libraries) +), final as ( select public_id, store_id, @@ -477,6 +567,29 @@ final as ( additional_valid_principals, 'ssh' as type from ssh_cert_libs + union + select public_id, + store_id, + project_id, + name, + description, + create_time, + update_time, + version, + vault_path, + credential_type, + null as http_method, -- Add to make union uniform + null as http_request_body, -- Add to make union uniform + null as username, -- Add to make union uniform + null as key_type, -- Add to make union uniform + null as key_bits, -- Add to make union uniform + null as ttl, -- Add to make union uniform + null as key_id, -- Add to make union uniform + null as critical_options, -- Add to make union uniform + null as extensions, -- Add to make union uniform + null as additional_valid_principals, -- Add to make union uniform + 'ldap' as type + from ldap_libs ) select * from final diff --git a/internal/credential/vault/repository_ldap_credential_library.go b/internal/credential/vault/repository_ldap_credential_library.go new file mode 100644 index 000000000..e3dd80622 --- /dev/null +++ b/internal/credential/vault/repository_ldap_credential_library.go @@ -0,0 +1,224 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package vault + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/oplog" + "github.com/hashicorp/go-dbw" +) + +// CreateLdapCredentialLibrary creates a new Vault LDAP credential library. It +// inserts the input domain object into the repository and returns the inserted +// domain object. No options are supported. +// +// The input domain object is not mutated, must contain a valid credential store +// id and must not contain a public id. +func (r *Repository) CreateLdapCredentialLibrary(ctx context.Context, projectId string, l *LdapCredentialLibrary, _ ...Option) (*LdapCredentialLibrary, error) { + const op = "vault.(Repository).CreateLdapCredentialLibrary" + if projectId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "no project id") + } + if l == nil || l.LdapCredentialLibrary == nil { + return nil, errors.New(ctx, errors.InvalidParameter, op, "nil input domain object") + } + if l.PublicId != "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "public id not empty") + } + if l.StoreId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "no store id") + } + if l.VaultPath == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "no vault path") + } + l = l.clone() + + if l.GetCredentialType() == "" { + l.LdapCredentialLibrary.CredentialType = string(globals.UsernamePasswordDomainCredentialType) + } + if l.GetCredentialType() != string(globals.UsernamePasswordDomainCredentialType) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid credential type") + } + + id, err := newLdapCredentialLibraryId(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + l.setId(id) + + oplogWrapper, err := r.kms.GetWrapper(ctx, projectId, kms.KeyPurposeOplog) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get oplog wrapper")) + } + + var insertedLdapCredLib *LdapCredentialLibrary + _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + insertedLdapCredLib = l.clone() + err = w.Create(ctx, insertedLdapCredLib, + db.WithOplog(oplogWrapper, insertedLdapCredLib.oplog(oplog.OpType_OP_TYPE_CREATE)), + ) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + return nil + }, + ) + if err != nil { + if errors.IsUniqueError(err) { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("in credential store: %s: name %s already exists", l.StoreId, l.Name)) + } + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("credential store: %s", l.StoreId)) + } + + return insertedLdapCredLib, nil +} + +// UpdateLdapCredentialLibrary updates an existing Vault LDAP credential library +// in the repository. It returns a domain object containing the updated state +// and the number of updated records. No options are supported. +// +// The input domain object is not mutated and must contain a valid public id. +func (r *Repository) UpdateLdapCredentialLibrary(ctx context.Context, projectId string, l *LdapCredentialLibrary, version uint32, fieldMaskPaths []string, _ ...Option) (*LdapCredentialLibrary, int, error) { + const op = "vault.(Repository).UpdateLdapCredentialLibrary" + if projectId == "" { + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "no project id") + } + if version == 0 { + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "missing version") + } + if l == nil || l.LdapCredentialLibrary == nil { + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "nil domain object") + } + if l.PublicId == "" { + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "missing public id") + } + + for _, f := range fieldMaskPaths { + switch { + case strings.EqualFold(nameField, f): + case strings.EqualFold(descriptionField, f): + case strings.EqualFold(vaultPathField, f): + default: + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidFieldMask, op, fmt.Sprintf("%q field mask path is unsupported", f)) + } + } + + dbMask, nullFields := dbw.BuildUpdatePaths( + map[string]any{ + nameField: l.Name, + descriptionField: l.Description, + vaultPathField: l.VaultPath, + }, fieldMaskPaths, nil, + ) + if len(dbMask) == 0 && len(nullFields) == 0 { + return nil, db.NoRowsAffected, errors.New(ctx, errors.EmptyFieldMask, op, "missing field mask") + } + + oplogWrapper, err := r.kms.GetWrapper(ctx, projectId, kms.KeyPurposeOplog) + if err != nil { + return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get oplog wrapper")) + } + + var updatedCredLib *LdapCredentialLibrary + var rowsUpdated int + _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(r db.Reader, w db.Writer) error { + updated, err := w.Update(ctx, l.clone(), dbMask, nullFields, + db.WithOplog(oplogWrapper, l.oplog(oplog.OpType_OP_TYPE_UPDATE)), + db.WithVersion(&version), + ) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to update credential library")) + } + if updated > 1 { + return errors.New(ctx, errors.MultipleRecords, op, "more than 1 credential library would have been updated") + } + rowsUpdated = updated + + updatedCredLib = allocLdapCredentialLibrary() + updatedCredLib.PublicId = l.GetPublicId() + err = r.LookupByPublicId(ctx, updatedCredLib) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("unable to retrieve updated credential library")) + } + return nil + }, + ) + if err != nil { + if errors.IsUniqueError(err) { + return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("name %s already exists: %s", l.Name, l.PublicId)) + } + return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("failed to update credential library %s", l.PublicId)) + } + + return updatedCredLib, rowsUpdated, nil +} + +// LookupLdapCredentialLibrary looks up an existing Vault LDAP credential +// library in the repository. It returns the domain object for the given public +// id, if it exists. Returns nil, nil if not found. No options are supported. +func (r *Repository) LookupLdapCredentialLibrary(ctx context.Context, publicId string, _ ...Option) (*LdapCredentialLibrary, error) { + const op = "vault.(Repository).LookupLdapCredentialLibrary" + if publicId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "no public id") + } + + l := allocLdapCredentialLibrary() + l.PublicId = publicId + err := r.reader.LookupByPublicId(ctx, l) + if err != nil { + if errors.IsNotFoundError(err) { + return nil, nil + } + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("lookup failed for id %s", publicId)) + } + + return l, nil +} + +// DeleteLdapCredentialLibrary deletes the Vault LDAP credential library with +// the given public id from the repository. Returns the number of records +// deleted. No options are supported. +func (r *Repository) DeleteLdapCredentialLibrary(ctx context.Context, projectId string, publicId string, _ ...Option) (int, error) { + const op = "vault.(Repository).DeleteLdapCredentialLibrary" + if publicId == "" { + return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "no public id") + } + if projectId == "" { + return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "no project id") + } + + l := allocLdapCredentialLibrary() + l.PublicId = publicId + + oplogWrapper, err := r.kms.GetWrapper(ctx, projectId, kms.KeyPurposeOplog) + if err != nil { + return db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get oplog wrapper")) + } + + var rowsDeleted int + _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) (err error) { + rowsDeleted, err = w.Delete(ctx, l, db.WithOplog(oplogWrapper, l.oplog(oplog.OpType_OP_TYPE_DELETE))) + if err == nil && rowsDeleted > 1 { + return errors.New(ctx, errors.MultipleRecords, op, "more than 1 credential library would have been deleted") + } + return err + }, + ) + if err != nil { + return db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("delete failed for id %s", l.PublicId)) + } + + return rowsDeleted, nil +} diff --git a/internal/credential/vault/repository_ldap_credential_library_test.go b/internal/credential/vault/repository_ldap_credential_library_test.go new file mode 100644 index 000000000..56b45168f --- /dev/null +++ b/internal/credential/vault/repository_ldap_credential_library_test.go @@ -0,0 +1,709 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package vault + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/credential/vault/store" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/scheduler" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestCreateLdapCredentialLibrary(t *testing.T) { + t.Parallel() + + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + cs := TestCredentialStores(t, conn, wrapper, proj.GetPublicId(), 1)[0] + + tests := []struct { + name string + inProjectId string + inDomainObj *LdapCredentialLibrary + expOut *LdapCredentialLibrary + expErr bool + expErrMsg string + }{ + { + name: "noProjectId", + inProjectId: "", + inDomainObj: nil, + expErr: true, + expErrMsg: "no project id", + }, + { + name: "nilDomainObject", + inProjectId: proj.GetPublicId(), + inDomainObj: nil, + expErr: true, + expErrMsg: "nil input domain object", + }, + { + name: "nilStoreObject", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{LdapCredentialLibrary: nil}, + expErr: true, + expErrMsg: "nil input domain object", + }, + { + name: "emptyObject", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{LdapCredentialLibrary: &store.LdapCredentialLibrary{}}, + expErr: true, + expErrMsg: "no store id", + }, + { + name: "withPublicId", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + PublicId: "cvllt_3o2iuhOpZ", + StoreId: cs.GetPublicId(), + Name: "withPublicId", + Description: "withPublicId Subtest", + VaultPath: "ldap/creds/dynamic1", + }, + }, + expErr: true, + expErrMsg: "public id not empty", + }, + { + name: "noStoreId", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: "", + Name: "noStoreId", + Description: "noStoreId Subtest", + VaultPath: "ldap/creds/dynamic1", + }, + }, + expErr: true, + expErrMsg: "no store id", + }, + { + name: "noVaultPath", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "noVaultPath", + Description: "noVaultPath Subtest", + VaultPath: "", + }, + }, + expErr: true, + expErrMsg: "no vault path", + }, + { + name: "invalidCredentialType", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "invalidCredentialType", + Description: "invalidCredentialType Subtest", + VaultPath: "ldap/creds/dynamic1", + CredentialType: "cred_type_that_doesnt_exist", + }, + }, + expErr: true, + expErrMsg: "invalid credential type", + }, + { + name: "invalidVaultPath1", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "invalidVaultPath1", + Description: "invalidVaultPath1 Subtest", + VaultPath: "ldap/bad/some/test/path", + }, + }, + expErr: true, + expErrMsg: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "invalidVaultPath2", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "invalidVaultPath2", + Description: "invalidVaultPath2 Subtest", + VaultPath: "ldap/static-credbad/some/test/path", + }, + }, + expErr: true, + expErrMsg: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "invalidVaultPath3", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "invalidVaultPath3", + Description: "invalidVaultPath3 Subtest", + VaultPath: "ldap/badstatic-cred/some/test/path", + }, + }, + expErr: true, + expErrMsg: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "invalidVaultPath4", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "invalidVaultPath4", + Description: "invalidVaultPath4 Subtest", + VaultPath: "ldap/credsbad/some/test/path", + }, + }, + expErr: true, + expErrMsg: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "invalidVaultPath5", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "invalidVaultPath5", + Description: "invalidVaultPath5 Subtest", + VaultPath: "ldap/badcreds/some/test/path", + }, + }, + expErr: true, + expErrMsg: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "validStaticCred1", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "validStaticCred1", + Description: "validStaticCred1 Subtest", + VaultPath: "ldap/static-cred/some/test/path", + }, + }, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validStaticCred1", + Description: "validStaticCred1 Subtest", + VaultPath: "ldap/static-cred/some/test/path", + }, + }, + }, + { + name: "validStaticCred2", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "validStaticCred2", + Description: "validStaticCred2 Subtest", + VaultPath: "/ldap/static-cred/path", + }, + }, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validStaticCred2", + Description: "validStaticCred2 Subtest", + VaultPath: "/ldap/static-cred/path", + }, + }, + }, + { + name: "validDynamicCreds1", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "validDynamicCreds1", + Description: "validDynamicCreds1 Subtest", + VaultPath: "ldap/creds/some/test/path", + }, + }, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validDynamicCreds1", + Description: "validDynamicCreds1 Subtest", + VaultPath: "ldap/creds/some/test/path", + }, + }, + }, + { + name: "validDynamicCreds2", + inProjectId: proj.GetPublicId(), + inDomainObj: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "validDynamicCreds2", + Description: "validDynamicCreds2 Subtest", + VaultPath: "/ldap/creds/path", + }, + }, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validDynamicCreds2", + Description: "validDynamicCreds2 Subtest", + VaultPath: "/ldap/creds/path", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kms := kms.TestKms(t, conn, wrapper) + sche := scheduler.TestScheduler(t, conn, wrapper) + rw := db.New(conn) + + repo, err := NewRepository(t.Context(), rw, rw, kms, sche) + require.NoError(t, err) + require.NotNil(t, repo) + + ldapCredLib, err := repo.CreateLdapCredentialLibrary(t.Context(), tt.inProjectId, tt.inDomainObj) + if tt.expErr { + require.ErrorContains(t, err, tt.expErrMsg) + require.Nil(t, ldapCredLib) + return + } + require.NoError(t, err) + require.NotNil(t, ldapCredLib) + + require.Contains(t, ldapCredLib.GetPublicId(), globals.VaultLdapCredentialLibraryPrefix) + require.NotEmpty(t, ldapCredLib.GetCreateTime()) + require.NotEmpty(t, ldapCredLib.GetUpdateTime()) + require.EqualValues(t, cs.GetPublicId(), ldapCredLib.GetStoreId()) + require.EqualValues(t, 1, ldapCredLib.GetVersion()) + require.EqualValues(t, string(globals.UsernamePasswordDomainCredentialType), ldapCredLib.GetCredentialType()) + require.EqualValues(t, tt.expOut.GetName(), ldapCredLib.GetName()) + require.EqualValues(t, tt.expOut.GetDescription(), ldapCredLib.GetDescription()) + require.EqualValues(t, tt.expOut.GetVaultPath(), ldapCredLib.GetVaultPath()) + }) + } +} + +func TestUpdateLdapCredentialLibrary(t *testing.T) { + t.Parallel() + + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + cs := TestCredentialStores(t, conn, wrapper, proj.GetPublicId(), 1)[0] + + kms := kms.TestKms(t, conn, wrapper) + sche := scheduler.TestScheduler(t, conn, wrapper) + rw := db.New(conn) + + repo, err := NewRepository(t.Context(), rw, rw, kms, sche) + require.NoError(t, err) + require.NotNil(t, repo) + + t.Run("nilDomainObject", func(t *testing.T) { + l, updated, err := repo.UpdateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), nil, 1, []string{}) + require.ErrorContains(t, err, "nil domain object") + require.EqualValues(t, 0, updated) + require.Nil(t, l) + }) + + t.Run("nilStoreObject", func(t *testing.T) { + l, updated, err := repo.UpdateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), + &LdapCredentialLibrary{LdapCredentialLibrary: nil}, 1, []string{}) + require.ErrorContains(t, err, "nil domain object") + require.EqualValues(t, 0, updated) + require.Nil(t, l) + }) + + t.Run("emptyDomainObject", func(t *testing.T) { + l, updated, err := repo.UpdateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), + &LdapCredentialLibrary{LdapCredentialLibrary: &store.LdapCredentialLibrary{}}, + 1, []string{}, + ) + require.ErrorContains(t, err, "missing public id") + require.EqualValues(t, 0, updated) + require.Nil(t, l) + }) + + t.Run("credLibDoesntExist", func(t *testing.T) { + l, updated, err := repo.UpdateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), + &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{PublicId: "doesnt_exist"}, + }, 1, []string{"name", "description"}, + ) + require.ErrorContains(t, err, "record not found, search issue") + require.EqualValues(t, 0, updated) + require.Nil(t, l) + }) + + updateNameFn := func(name string) func(*LdapCredentialLibrary) *LdapCredentialLibrary { + return func(lcl *LdapCredentialLibrary) *LdapCredentialLibrary { + lcl.Name = name + return lcl + } + } + updateDescriptionFn := func(description string) func(*LdapCredentialLibrary) *LdapCredentialLibrary { + return func(lcl *LdapCredentialLibrary) *LdapCredentialLibrary { + lcl.Description = description + return lcl + } + } + updateVaultPathFn := func(vaultPath string) func(*LdapCredentialLibrary) *LdapCredentialLibrary { + return func(lcl *LdapCredentialLibrary) *LdapCredentialLibrary { + lcl.VaultPath = vaultPath + return lcl + } + } + updateStoreIdFn := func() func(*LdapCredentialLibrary) *LdapCredentialLibrary { + return func(lcl *LdapCredentialLibrary) *LdapCredentialLibrary { + lcl.StoreId = "this_is_immutable" + return lcl + } + } + + tests := []struct { + name string + inProjectId string + inVersion uint32 + inFieldMask []string + changeFns []func(*LdapCredentialLibrary) *LdapCredentialLibrary + expUpdate bool + expOut *LdapCredentialLibrary + expErr bool + expErrStr string + }{ + { + name: "noProjectId", + inProjectId: "", + inVersion: 1, + inFieldMask: []string{"somepath"}, + expErr: true, + expErrStr: "no project id", + }, + { + name: "noVersion", + inProjectId: proj.GetPublicId(), + inVersion: 0, + inFieldMask: []string{"somepath"}, + expErr: true, + expErrStr: "missing version", + }, + { + name: "missingFieldMask", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{}, + expErr: true, + expErrStr: "missing field mask", + }, + { + name: "unsupportedFieldInFieldMask", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"name", "storeId"}, + expErr: true, + expErrStr: `"storeId" field mask path is unsupported`, + }, + { + name: "updateToInvalidVaultPath1", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"vaultPath"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateVaultPathFn("ldap/credbad/new"), + }, + expErr: true, + expErrStr: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "updateToInvalidVaultPath2", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"vaultPath"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateVaultPathFn("ldap/static-credbad/new"), + }, + expErr: true, + expErrStr: "vault_path_must_have_staticcred_or_creds constraint failed", + }, + { + name: "updateAndFieldMaskMismatch", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"name"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateDescriptionFn("updateAndFieldMaskMismatch Updated Description"), + }, + expUpdate: false, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "TestUpdateLdapCredentialLibrary/updateAndFieldMaskMismatch Original Name", + Description: "TestUpdateLdapCredentialLibrary/updateAndFieldMaskMismatch Original Description", + VaultPath: "ldap/creds/path", + }, + }, + }, + { + name: "validUpdateName", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"name"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateNameFn("validUpdateName Subtest Updated Name"), + }, + expUpdate: true, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validUpdateName Subtest Updated Name", + Description: "TestUpdateLdapCredentialLibrary/validUpdateName Original Description", + VaultPath: "ldap/creds/path", + }, + }, + }, + { + name: "validUpdateDescription", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"description"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateDescriptionFn("validUpdateDescription Subtest Updated Description"), + }, + expUpdate: true, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "TestUpdateLdapCredentialLibrary/validUpdateDescription Original Name", + Description: "validUpdateDescription Subtest Updated Description", + VaultPath: "ldap/creds/path", + }, + }, + }, + { + name: "validUpdateVaultPath", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"vaultPath"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateVaultPathFn("ldap/creds/updated/path/new"), + }, + expUpdate: true, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "TestUpdateLdapCredentialLibrary/validUpdateVaultPath Original Name", + Description: "TestUpdateLdapCredentialLibrary/validUpdateVaultPath Original Description", + VaultPath: "ldap/creds/updated/path/new", + }, + }, + }, + { + name: "validUpdateMultipleFields", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"name", "description", "vaultPath"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateNameFn("validUpdateMultipleFields Updated Name"), + updateDescriptionFn("validUpdateMultipleFields Updated Description"), + updateVaultPathFn("ldap/static-cred/updated/path/new"), + }, + expUpdate: true, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validUpdateMultipleFields Updated Name", + Description: "validUpdateMultipleFields Updated Description", + VaultPath: "ldap/static-cred/updated/path/new", + }, + }, + }, + { + name: "validUpdateNameExtraChangesNotInFieldMask", + inProjectId: proj.GetPublicId(), + inVersion: 1, + inFieldMask: []string{"name"}, + changeFns: []func(*LdapCredentialLibrary) *LdapCredentialLibrary{ + updateNameFn("validUpdateNameExtraChanges Updated Name"), + updateDescriptionFn("validUpdateNameExtraChanges Updated Description"), + updateVaultPathFn("ldap/creds/updated/path/new"), + updateStoreIdFn(), + }, + expUpdate: true, + expOut: &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + Name: "validUpdateNameExtraChanges Updated Name", + Description: "TestUpdateLdapCredentialLibrary/validUpdateNameExtraChangesNotInFieldMask Original Description", + VaultPath: "ldap/creds/path", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createdLib, err := repo.CreateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: t.Name() + " Original Name", + Description: t.Name() + " Original Description", + VaultPath: "ldap/creds/path", + }, + }) + require.NoError(t, err) + require.NotNil(t, createdLib) + + inDomainObj := createdLib.clone() + for _, changeFn := range tt.changeFns { + inDomainObj = changeFn(inDomainObj) + } + + updatedLdapLib, updated, err := repo.UpdateLdapCredentialLibrary(t.Context(), + tt.inProjectId, inDomainObj, tt.inVersion, tt.inFieldMask, + ) + if tt.expErr { + require.ErrorContains(t, err, tt.expErrStr) + require.Nil(t, updatedLdapLib) + require.EqualValues(t, 0, updated) + return + } + require.NoError(t, err) + require.NotNil(t, updatedLdapLib) + require.NotNil(t, updatedLdapLib.LdapCredentialLibrary) + + if tt.expUpdate { + require.Greater(t, updatedLdapLib.GetUpdateTime().AsTime(), createdLib.GetUpdateTime().AsTime()) + require.EqualValues(t, createdLib.GetVersion()+1, updatedLdapLib.GetVersion()) + } else { + require.EqualValues(t, createdLib.GetUpdateTime().AsTime(), updatedLdapLib.GetUpdateTime().AsTime()) + require.EqualValues(t, createdLib.GetVersion(), updatedLdapLib.GetVersion()) + } + require.EqualValues(t, 1, updated) + require.EqualValues(t, createdLib.GetPublicId(), updatedLdapLib.GetPublicId()) + require.EqualValues(t, createdLib.GetCreateTime().AsTime(), updatedLdapLib.GetCreateTime().AsTime()) + require.EqualValues(t, createdLib.GetStoreId(), updatedLdapLib.GetStoreId()) + require.EqualValues(t, createdLib.GetCredentialType(), updatedLdapLib.GetCredentialType()) + require.EqualValues(t, tt.expOut.GetName(), updatedLdapLib.GetName()) + require.EqualValues(t, tt.expOut.GetDescription(), updatedLdapLib.GetDescription()) + require.EqualValues(t, tt.expOut.GetVaultPath(), updatedLdapLib.GetVaultPath()) + }) + } +} + +func TestLookupLdapCredentialLibrary(t *testing.T) { + t.Parallel() + + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + cs := TestCredentialStores(t, conn, wrapper, proj.GetPublicId(), 1)[0] + + kms := kms.TestKms(t, conn, wrapper) + sche := scheduler.TestScheduler(t, conn, wrapper) + rw := db.New(conn) + + repo, err := NewRepository(t.Context(), rw, rw, kms, sche) + require.NoError(t, err) + require.NotNil(t, repo) + + t.Run("noPublicId", func(t *testing.T) { + l, err := repo.LookupLdapCredentialLibrary(t.Context(), "") + require.ErrorContains(t, err, "no public id") + require.Nil(t, l) + }) + + t.Run("credLibNotFound", func(t *testing.T) { + l, err := repo.LookupLdapCredentialLibrary(t.Context(), "alibthat_doesnt_exist") + require.NoError(t, err) + require.Nil(t, l) + }) + + t.Run("success", func(t *testing.T) { + createdLib, err := repo.CreateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "success", + Description: "success Subtest", + VaultPath: "ldap/creds/path", + }, + }) + require.NoError(t, err) + require.NotNil(t, createdLib) + + lookupLib, err := repo.LookupLdapCredentialLibrary(t.Context(), createdLib.GetPublicId()) + require.NoError(t, err) + require.Empty(t, cmp.Diff(createdLib, lookupLib, protocmp.Transform())) + }) +} + +func TestDeleteLdapCredentialLibrary(t *testing.T) { + t.Parallel() + + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + cs := TestCredentialStores(t, conn, wrapper, proj.GetPublicId(), 1)[0] + + kms := kms.TestKms(t, conn, wrapper) + sche := scheduler.TestScheduler(t, conn, wrapper) + rw := db.New(conn) + + repo, err := NewRepository(t.Context(), rw, rw, kms, sche) + require.NoError(t, err) + require.NotNil(t, repo) + + t.Run("noPublicId", func(t *testing.T) { + deleted, err := repo.DeleteLdapCredentialLibrary(t.Context(), proj.GetPublicId(), "") + require.ErrorContains(t, err, "no public id") + require.EqualValues(t, 0, deleted) + }) + + t.Run("noProjectId", func(t *testing.T) { + deleted, err := repo.DeleteLdapCredentialLibrary(t.Context(), "", "cvllt_doesntexist") + require.ErrorContains(t, err, "no project id") + require.EqualValues(t, 0, deleted) + }) + + t.Run("credLibDoesntExist", func(t *testing.T) { + deleted, err := repo.DeleteLdapCredentialLibrary(t.Context(), proj.GetPublicId(), "cvllt_doesntexist") + require.NoError(t, err) + require.EqualValues(t, 0, deleted) + }) + + t.Run("success", func(t *testing.T) { + createdLib, err := repo.CreateLdapCredentialLibrary(t.Context(), proj.GetPublicId(), &LdapCredentialLibrary{ + LdapCredentialLibrary: &store.LdapCredentialLibrary{ + StoreId: cs.GetPublicId(), + Name: "success", + Description: "success Subtest", + VaultPath: "ldap/creds/path", + }, + }) + require.NoError(t, err) + require.NotNil(t, createdLib) + + lookupLib, err := repo.LookupLdapCredentialLibrary(t.Context(), createdLib.GetPublicId()) + require.NoError(t, err) + require.Empty(t, cmp.Diff(createdLib, lookupLib, protocmp.Transform())) + + deleted, err := repo.DeleteLdapCredentialLibrary(t.Context(), proj.GetPublicId(), lookupLib.GetPublicId()) + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + + lookupLib2, err := repo.LookupLdapCredentialLibrary(t.Context(), createdLib.GetPublicId()) + require.NoError(t, err) + require.Nil(t, lookupLib2) + }) +} diff --git a/internal/credential/vault/store/vault.pb.go b/internal/credential/vault/store/vault.pb.go index 7f22e504c..637fa4fa0 100644 --- a/internal/credential/vault/store/vault.pb.go +++ b/internal/credential/vault/store/vault.pb.go @@ -1269,6 +1269,134 @@ func (x *SshPrivateKeyOverride) GetPrivateKeyPassphraseAttribute() string { return "" } +type LdapCredentialLibrary struct { + state protoimpl.MessageState `protogen:"open.v1"` + // public_id is a surrogate key suitable for use in a public API. + // @inject_tag: `gorm:"primary_key"` + PublicId string `protobuf:"bytes,1,opt,name=public_id,json=publicId,proto3" json:"public_id,omitempty" gorm:"primary_key"` + // create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + CreateTime *timestamp.Timestamp `protobuf:"bytes,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" gorm:"default:current_timestamp"` + // update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + UpdateTime *timestamp.Timestamp `protobuf:"bytes,3,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty" gorm:"default:current_timestamp"` + // name is optional. If set, it must be unique within project_id. + // @inject_tag: `gorm:"default:null"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty" gorm:"default:null"` + // description is optional. + // @inject_tag: `gorm:"default:null"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty" gorm:"default:null"` + // store_id of the owning vault credential store. + // It must be set. + // @inject_tag: `gorm:"not_null"` + StoreId string `protobuf:"bytes,6,opt,name=store_id,json=storeId,proto3" json:"store_id,omitempty" gorm:"not_null"` + // version allows optimistic locking of the resource. + // @inject_tag: `gorm:"default:null"` + Version uint32 `protobuf:"varint,7,opt,name=version,proto3" json:"version,omitempty" gorm:"default:null"` + // vault_path is the path in Vault to request credentials from. + // It must be set. + // @inject_tag: `gorm:"not_null"` + VaultPath string `protobuf:"bytes,8,opt,name=vault_path,json=vaultPath,proto3" json:"vault_path,omitempty" gorm:"not_null"` + // credential_type is always ssh_certificate + // @inject_tag: `gorm:"default:null"` + CredentialType string `protobuf:"bytes,9,opt,name=credential_type,json=credentialType,proto3" json:"credential_type,omitempty" gorm:"default:null"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LdapCredentialLibrary) Reset() { + *x = LdapCredentialLibrary{} + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LdapCredentialLibrary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LdapCredentialLibrary) ProtoMessage() {} + +func (x *LdapCredentialLibrary) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LdapCredentialLibrary.ProtoReflect.Descriptor instead. +func (*LdapCredentialLibrary) Descriptor() ([]byte, []int) { + return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP(), []int{9} +} + +func (x *LdapCredentialLibrary) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *LdapCredentialLibrary) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *LdapCredentialLibrary) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +func (x *LdapCredentialLibrary) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *LdapCredentialLibrary) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *LdapCredentialLibrary) GetStoreId() string { + if x != nil { + return x.StoreId + } + return "" +} + +func (x *LdapCredentialLibrary) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *LdapCredentialLibrary) GetVaultPath() string { + if x != nil { + return x.VaultPath + } + return "" +} + +func (x *LdapCredentialLibrary) GetCredentialType() string { + if x != nil { + return x.CredentialType + } + return "" +} + var File_controller_storage_credential_vault_store_v1_vault_proto protoreflect.FileDescriptor const file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc = "" + @@ -1424,7 +1552,23 @@ const file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc = "" "library_id\x18\x01 \x01(\tR\tlibraryId\x12-\n" + "\x12username_attribute\x18\x02 \x01(\tR\x11usernameAttribute\x122\n" + "\x15private_key_attribute\x18\x03 \x01(\tR\x13privateKeyAttribute\x12G\n" + - " private_key_passphrase_attribute\x18\x04 \x01(\tR\x1dprivateKeyPassphraseAttributeBEZCgithub.com/hashicorp/boundary/internal/credential/vault/store;storeb\x06proto3" + " private_key_passphrase_attribute\x18\x04 \x01(\tR\x1dprivateKeyPassphraseAttribute\"\xd5\x03\n" + + "\x15LdapCredentialLibrary\x12\x1b\n" + + "\tpublic_id\x18\x01 \x01(\tR\bpublicId\x12K\n" + + "\vcreate_time\x18\x02 \x01(\v2*.controller.storage.timestamp.v1.TimestampR\n" + + "createTime\x12K\n" + + "\vupdate_time\x18\x03 \x01(\v2*.controller.storage.timestamp.v1.TimestampR\n" + + "updateTime\x12$\n" + + "\x04name\x18\x04 \x01(\tB\x10\xc2\xdd)\f\n" + + "\x04Name\x12\x04nameR\x04name\x12@\n" + + "\vdescription\x18\x05 \x01(\tB\x1e\xc2\xdd)\x1a\n" + + "\vDescription\x12\vdescriptionR\vdescription\x12\x19\n" + + "\bstore_id\x18\x06 \x01(\tR\astoreId\x12\x18\n" + + "\aversion\x18\a \x01(\rR\aversion\x12?\n" + + "\n" + + "vault_path\x18\b \x01(\tB \xc2\xdd)\x1c\n" + + "\tVaultPath\x12\x0fattributes.pathR\tvaultPath\x12'\n" + + "\x0fcredential_type\x18\t \x01(\tR\x0ecredentialTypeBEZCgithub.com/hashicorp/boundary/internal/credential/vault/store;storeb\x06proto3" var ( file_controller_storage_credential_vault_store_v1_vault_proto_rawDescOnce sync.Once @@ -1438,7 +1582,7 @@ func file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP() return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescData } -var file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_controller_storage_credential_vault_store_v1_vault_proto_goTypes = []any{ (*CredentialStore)(nil), // 0: controller.storage.credential.vault.store.v1.CredentialStore (*Token)(nil), // 1: controller.storage.credential.vault.store.v1.Token @@ -1449,29 +1593,32 @@ var file_controller_storage_credential_vault_store_v1_vault_proto_goTypes = []an (*UsernamePasswordOverride)(nil), // 6: controller.storage.credential.vault.store.v1.UsernamePasswordOverride (*UsernamePasswordDomainOverride)(nil), // 7: controller.storage.credential.vault.store.v1.UsernamePasswordDomainOverride (*SshPrivateKeyOverride)(nil), // 8: controller.storage.credential.vault.store.v1.SshPrivateKeyOverride - (*timestamp.Timestamp)(nil), // 9: controller.storage.timestamp.v1.Timestamp + (*LdapCredentialLibrary)(nil), // 9: controller.storage.credential.vault.store.v1.LdapCredentialLibrary + (*timestamp.Timestamp)(nil), // 10: controller.storage.timestamp.v1.Timestamp } var file_controller_storage_credential_vault_store_v1_vault_proto_depIdxs = []int32{ - 9, // 0: controller.storage.credential.vault.store.v1.CredentialStore.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 1: controller.storage.credential.vault.store.v1.CredentialStore.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 2: controller.storage.credential.vault.store.v1.CredentialStore.delete_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 3: controller.storage.credential.vault.store.v1.Token.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 4: controller.storage.credential.vault.store.v1.Token.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 5: controller.storage.credential.vault.store.v1.Token.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 6: controller.storage.credential.vault.store.v1.Token.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 7: controller.storage.credential.vault.store.v1.CredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 8: controller.storage.credential.vault.store.v1.CredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 9: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 10: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 11: controller.storage.credential.vault.store.v1.Credential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 12: controller.storage.credential.vault.store.v1.Credential.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 13: controller.storage.credential.vault.store.v1.Credential.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp - 9, // 14: controller.storage.credential.vault.store.v1.Credential.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 10, // 0: controller.storage.credential.vault.store.v1.CredentialStore.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 1: controller.storage.credential.vault.store.v1.CredentialStore.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 2: controller.storage.credential.vault.store.v1.CredentialStore.delete_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 3: controller.storage.credential.vault.store.v1.Token.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 4: controller.storage.credential.vault.store.v1.Token.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 5: controller.storage.credential.vault.store.v1.Token.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 6: controller.storage.credential.vault.store.v1.Token.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 7: controller.storage.credential.vault.store.v1.CredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 8: controller.storage.credential.vault.store.v1.CredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 9: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 10: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 11: controller.storage.credential.vault.store.v1.Credential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 12: controller.storage.credential.vault.store.v1.Credential.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 13: controller.storage.credential.vault.store.v1.Credential.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 14: controller.storage.credential.vault.store.v1.Credential.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 15: controller.storage.credential.vault.store.v1.LdapCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 10, // 16: controller.storage.credential.vault.store.v1.LdapCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 17, // [17:17] is the sub-list for method output_type + 17, // [17:17] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_controller_storage_credential_vault_store_v1_vault_proto_init() } @@ -1485,7 +1632,7 @@ func file_controller_storage_credential_vault_store_v1_vault_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc), len(file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/credential/vault/supported.go b/internal/credential/vault/supported.go index b65ad6317..fcc8e8fd9 100644 --- a/internal/credential/vault/supported.go +++ b/internal/credential/vault/supported.go @@ -7,6 +7,8 @@ package vault import ( + "bytes" + "context" "crypto/tls" "fmt" "net/http" @@ -32,15 +34,18 @@ const DefaultVaultVersion = "1.15.1" var ( vaultRepository = "hashicorp/vault" postgresRepository = "postgres" + ldapRepository = "osixia/openldap" ) func init() { newVaultServer = gotNewServer + mountLdapServer = gotLdapServer mountDatabase = gotMountDatabase mirror := os.Getenv("DOCKER_MIRROR") if mirror != "" { vaultRepository = strings.Join([]string{mirror, vaultRepository}, "/") + ldapRepository = strings.Join([]string{mirror, ldapRepository}, "/") postgresRepository = strings.Join([]string{mirror, postgresRepository}, "/") } } @@ -217,20 +222,233 @@ func gotNewServer(t testing.TB, opt ...TestOption) *TestVaultServer { return server } +func gotLdapServer(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestLdapServer { + require.NotNil(t, v.pool) + require.NotNil(t, v.network, "Vault server must be created with docker network") + require.NotNil(t, v.vaultContainer, "vault container doesn't exist") + require.Nil(t, v.ldapContainer, "ldap container exists") + + pool, ok := v.pool.(*dockertest.Pool) + require.True(t, ok) + nw, ok := v.network.(*dockertest.Network) + require.True(t, ok) + + opts := getTestOpts(t, opt...) + if opts.ldapRootDomain == "" { + opts.ldapRootDomain = "example.org" + } + if opts.bindPass == "" { + opts.bindPass = "admin" + } + + dcl := []string{} + for d := range strings.SplitSeq(opts.ldapRootDomain, ".") { + dcl = append(dcl, "dc="+d) + } + dc := strings.Join(dcl, ",") + dn := fmt.Sprintf("cn=admin,%s", dc) + + server := &TestLdapServer{ + BindDomain: opts.ldapRootDomain, + BindPass: opts.bindPass, + BindDn: dn, + } + + userLdif := func(username, dc string) string { + template := ` + dn: cn=%[1]s,%[2]s + objectClass: inetOrgPerson + cn: %[1]s + sn: %[1]s + uid: %[1]s + userPassword: password + ` + template = fmt.Sprintf(template, username, dc) + // LDIF is whitespace sensitive, so remove the leading tab characters we + // have from having indented the template. + return strings.ReplaceAll(template, "\t", "") + } + userEinsteinLdif := userLdif("einstein", dc) + userNewtonLdif := userLdif("newton", dc) + + groupScientistsLdifTemplate := ` + dn: cn=scientists,%[1]s + objectClass: groupOfUniqueNames + cn: scientists + uniqueMember: cn=einstein,%[1]s + uniqueMember: cn=netwon,%[1]s + ` + groupLdif := fmt.Sprintf(groupScientistsLdifTemplate, dc) + // LDIF is whitespace sensitive, so remove the leading tab characters we + // have from having indented the template. + groupLdif = strings.ReplaceAll(groupLdif, "\t", "") + + dir, err := base62.Random(12) + require.NoError(t, err) + dir = path.Join("/tmp", dir) + outdir := path.Join(dir, "ldap") + require.NoError(t, os.Mkdir(dir, os.ModePerm)) + require.NoError(t, os.Mkdir(outdir, os.ModePerm)) + require.NoError(t, os.WriteFile(fmt.Sprintf("%s/%s", outdir, "userEinstein.ldif"), []byte(userEinsteinLdif), 0o666)) + require.NoError(t, os.WriteFile(fmt.Sprintf("%s/%s", outdir, "userNewton.ldif"), []byte(userNewtonLdif), 0o666)) + require.NoError(t, os.WriteFile(fmt.Sprintf("%s/%s", outdir, "group.ldif"), []byte(groupLdif), 0o666)) + + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: ldapRepository, + Tag: "latest", + Networks: []*dockertest.Network{nw}, + Mounts: []string{fmt.Sprintf("%s:/tmp/ldap", outdir)}, + Env: []string{ + fmt.Sprintf("LDAP_DOMAIN=%s", server.BindDomain), + fmt.Sprintf("LDAP_ADMIN_PASSWORD=%s", server.BindPass), + }, + }) + require.NoError(t, err) + t.Cleanup(func() { cleanupResource(t, pool, resource) }) + v.ldapContainer = resource + server.Url = fmt.Sprintf("ldap://%s", resource.GetIPInNetwork(nw)) + + // Wait until the container is healthy or timeout. + timeout := 10 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), timeout) + t.Cleanup(cancel) + for loop := true; loop; { + select { + case <-ctx.Done(): + t.Fatalf("LDAP server was not healthy after trying for %s", timeout) + default: + buf := bytes.NewBuffer(nil) + exitCode, _ := resource.Exec([]string{ + "ldapwhoami", "-x", "-H", "ldap://localhost", "-D", server.BindDn, "-w", server.BindPass, + }, dockertest.ExecOptions{ + StdOut: buf, + }) + // ldapwhoami will output the caller's identity in the form of a DN. + // Because we're using the admin user here, we can just look for the + // BindDn in stdout. + if exitCode == 0 || strings.Contains(buf.String(), server.BindDn) { + loop = false + } + } + } + + buf := bytes.NewBuffer(nil) + ebuf := bytes.NewBuffer(nil) + exitCode, err := resource.Exec([]string{"ldapadd", "-x", "-H", "ldap://localhost", "-D", server.BindDn, "-w", server.BindPass, "-f", "/tmp/ldap/userEinstein.ldif"}, dockertest.ExecOptions{ + StdOut: buf, + StdErr: ebuf, + }) + require.NoError(t, err) + require.Empty(t, ebuf) + require.Equal(t, 0, exitCode) + + buf.Reset() + ebuf.Reset() + exitCode, err = resource.Exec([]string{"ldapadd", "-x", "-H", "ldap://localhost", "-D", server.BindDn, "-w", server.BindPass, "-f", "/tmp/ldap/userNewton.ldif"}, dockertest.ExecOptions{ + StdOut: buf, + StdErr: ebuf, + }) + require.NoError(t, err) + require.Empty(t, ebuf) + require.Equal(t, 0, exitCode) + + buf.Reset() + ebuf.Reset() + exitCode, err = resource.Exec([]string{"ldapadd", "-x", "-H", "ldap://localhost", "-D", server.BindDn, "-w", server.BindPass, "-f", "/tmp/ldap/group.ldif"}, dockertest.ExecOptions{ + StdOut: buf, + StdErr: ebuf, + }) + require.NoError(t, err) + require.Empty(t, ebuf) + require.Equal(t, 0, exitCode) + + // Mount and configure Vault LDAP secrets engine. + v.MountLdap(t, WithBindDn(server.BindDn), WithBindPass(server.BindPass), WithLdapUrl(server.Url)) + + vc := v.client(t).cl + mounts, err := vc.Sys().ListMounts() + require.NoError(t, err) + require.Contains(t, mounts, "ldap/") + + // Add static roles that correspond to the LDAP server users. + _, err = vc.Logical().Write("ldap/static-role/einstein", map[string]any{ + "dn": "cn=einstein,dc=example,dc=org", + "username": "einstein", + "rotation_period": "24h", + }) + require.NoError(t, err) + + _, err = vc.Logical().Write("ldap/static-role/myorg/myproject/newton", map[string]any{ + "dn": "cn=newton,dc=example,dc=org", + "username": "newton", + "rotation_period": "24h", + }) + require.NoError(t, err) + + // Setup LDAP dynamic credentials. + ldifCreateTemplate := ` + dn: cn={{.Username}},dc=example,dc=org + changetype: add + objectClass: inetOrgPerson + cn: {{.Username}} + sn: {{.Username}} + uid: {{.Username}} + userPassword:{{.Password}} + + dn: cn=scientists,dc=example,dc=org + changetype: modify + add: uniqueMember + uniqueMember: cn={{.Username}},dc=example,dc=org + -` + ldifCreateTemplate = strings.ReplaceAll(ldifCreateTemplate, "\t", "") + + ldifDeleteTemplate := ` + dn: cn={{.Username}},dc=example,dc=org + changetype: delete + ` + ldifDeleteTemplate = strings.ReplaceAll(ldifDeleteTemplate, "\t", "") + + _, err = vc.Logical().Write("ldap/role/scientists", map[string]any{ + "username_template": "b_{{.DisplayName}}_{{.RoleName}}_{{random 10}}_{{unix_time}}", + "creation_ldif": ldifCreateTemplate, + "deletion_ldif": ldifDeleteTemplate, + "rollback_ldif": ldifDeleteTemplate, + "default_ttl": "1h", + "max_ttl": "24h", + }) + require.NoError(t, err) + + _, err = vc.Logical().Write("ldap/role/myorg/myproject/scientists", map[string]any{ + "username_template": "b_{{.DisplayName}}_{{.RoleName}}_{{random 10}}_{{unix_time}}", + "creation_ldif": ldifCreateTemplate, + "deletion_ldif": ldifDeleteTemplate, + "rollback_ldif": ldifDeleteTemplate, + "default_ttl": "1h", + "max_ttl": "24h", + }) + require.NoError(t, err) + + return server +} + func gotMountDatabase(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestDatabase { require := require.New(t) - require.Nil(v.postgresContainer, "postgres container exists") + require.NotNil(v.pool) require.NotNil(v.network, "Vault server must be created with docker network") + require.NotNil(v.vaultContainer, "vault container doesn't exist") + require.Nil(v.postgresContainer, "postgres container exists") pool, ok := v.pool.(*dockertest.Pool) require.True(ok) - network, ok := v.network.(*dockertest.Network) + nw, ok := v.network.(*dockertest.Network) + require.True(ok) + vaultContainer, ok := v.vaultContainer.(*dockertest.Resource) require.True(ok) dockerOptions := &dockertest.RunOptions{ Repository: postgresRepository, Tag: globals.MinimumSupportedPostgresVersion, - Networks: []*dockertest.Network{network}, + Networks: []*dockertest.Network{nw}, Env: []string{"POSTGRES_PASSWORD=password", "POSTGRES_DB=boundarytest"}, } @@ -316,13 +534,10 @@ func gotMountDatabase(t testing.TB, v *TestVaultServer, opt ...TestOption) *Test } v.addPolicy(t, "database", pc) - vaultContainer, ok := v.vaultContainer.(*dockertest.Resource) - require.True(ok) - - t.Log(vaultContainer.GetIPInNetwork(network)) + t.Log(vaultContainer.GetIPInNetwork(nw)) // Configure PostgreSQL secrets engine - connUrl := fmt.Sprintf("postgresql://{{username}}:{{password}}@%s:5432/boundarytest?sslmode=disable", resource.GetIPInNetwork(network)) + connUrl := fmt.Sprintf("postgresql://{{username}}:{{password}}@%s:5432/boundarytest?sslmode=disable", resource.GetIPInNetwork(nw)) t.Log(connUrl) postgresConfPath := path.Join(mountPath, "config/postgresql") diff --git a/internal/credential/vault/testing.go b/internal/credential/vault/testing.go index cb27228a8..e4bff5128 100644 --- a/internal/credential/vault/testing.go +++ b/internal/credential/vault/testing.go @@ -180,6 +180,44 @@ func TestSSHCertificateCredentialLibraries(t testing.TB, conn *db.DB, _ wrapping return libs } +// TestLdapCredentialLibraries creates `count` number of Vault LDAP credential +// libraries in the provided db with the provided credential store. If any +// errors are encountered, the test will fail. +func TestLdapCredentialLibraries(t testing.TB, conn *db.DB, _ wrapping.Wrapper, storeId string, count int) []*LdapCredentialLibrary { + t.Helper() + w := db.New(conn) + + var libs []*LdapCredentialLibrary + for i := range count { + var staticOrDynamic string + switch mrand.Intn(2) { + case 0: + staticOrDynamic = "static-cred" + case 1: + staticOrDynamic = "creds" + } + + lib, err := NewLdapCredentialLibrary(storeId, fmt.Sprintf("ldap/%s/role-%d", staticOrDynamic, i)) + require.NoError(t, err) + require.NotNil(t, lib) + + libId, err := newLdapCredentialLibraryId(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, libId) + + lib.PublicId = libId + _, err = w.DoTx(t.Context(), db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + return w.Create(t.Context(), lib) + }, + ) + require.NoError(t, err) + libs = append(libs, lib) + } + + return libs +} + // TestCredentials creates count number of vault credentials in the provided DB with // the provided library id and session id. If any errors are encountered // during the creation of the credentials, the test will fail. @@ -532,6 +570,10 @@ type testOptions struct { clientKey *ecdsa.PrivateKey vaultVersion string serverCertHostNames []string + bindDn string + bindPass string + ldapUrl string + ldapRootDomain string } func getDefaultTestOptions(t testing.TB) testOptions { @@ -666,6 +708,40 @@ func WithAllowedExtension(e string) TestOption { } } +// WithBindDn sets the distinguished name to be used by the Vault LDAP secrets +// engine. +func WithBindDn(dn string) TestOption { + return func(t testing.TB, o *testOptions) { + t.Helper() + o.bindDn = dn + } +} + +// WithBindPass sets the password to be used by the Vault LDAP secrets engine. +// It is also used to set the admin user password in the LDAP server. +func WithBindPass(p string) TestOption { + return func(t testing.TB, o *testOptions) { + t.Helper() + o.bindPass = p + } +} + +// WithLdapUrl sets the URL to be used by the Vault LDAP secrets engine. +func WithLdapUrl(u string) TestOption { + return func(t testing.TB, o *testOptions) { + t.Helper() + o.ldapUrl = u + } +} + +// WithLdapRootDomain controls the LDAP server's root domain. +func WithLdapRootDomain(d string) TestOption { + return func(t testing.TB, o *testOptions) { + t.Helper() + o.ldapRootDomain = d + } +} + // TestOrphanToken sets the token orphan option to b. // The orphan option is true by default. func TestOrphanToken(b bool) TestOption { @@ -916,6 +992,81 @@ func (v *TestVaultServer) MountSSH(t testing.TB, opt ...TestOption) *vault.Secre return s } +// MountLdap mounts the Vault LDAP secrets engine on a Vault instance and +// initializes it. +// +// Supported options include: +// - WithTestMountPath: Sets the mount path. Defaults to "ldap". +// - WithBindDn: Sets the BindDN to be used by the Vault LDAP secrets engine. +// Defaults to "cn=admin,dc=example,dc=org". +// - WithBindPass: Sets the password to be used by the Vault LDAP secrets +// engine. Defaults to "admin". +// - WithLdapUrl: Sets the URL of the LDAP server to be used by the Vault LDAP +// secrets engine. Defaults to "ldap://ldap". +// +// MountLdap also adds a Vault policy named 'ldap' to the standard set of +// policies attached to tokens created with v.CreateToken. The policy is defined +// as: +// +// path "/*" { +// capabilities = ["create", "read", "update", "delete", "list"] +// } +func (v *TestVaultServer) MountLdap(t testing.TB, opt ...TestOption) { + require := require.New(t) + opts := getTestOpts(t, opt...) + vc := v.client(t).cl + + maxTTL := 24 * time.Hour + if t, ok := t.(*testing.T); ok { + if deadline, ok := t.Deadline(); ok { + maxTTL = time.Until(deadline) * 2 + } + } + + defaultTTL := maxTTL / 2 + mountInput := &vault.MountInput{ + Type: "ldap", + Description: t.Name(), + Config: vault.MountConfigInput{ + DefaultLeaseTTL: defaultTTL.String(), + MaxLeaseTTL: maxTTL.String(), + }, + } + mountPath := opts.mountPath + if mountPath == "" { + mountPath = "ldap/" + } + require.True(mountPath[len(mountPath)-1] == '/', "mount path must end with a slash") + require.NoError(vc.Sys().Mount(mountPath, mountInput)) + + policyPath := fmt.Sprintf("%s*", mountPath) + pc := pathCapabilities{ + policyPath: createCapability | readCapability | updateCapability | deleteCapability | listCapability, + } + v.addPolicy(t, "ldap", pc) + + // Create and write LDAP server config + bindDn := opts.bindDn + if bindDn == "" { + bindDn = "cn=admin,dc=example,dc=org" + } + bindPass := opts.bindPass + if bindPass == "" { + bindPass = "admin" + } + url := opts.ldapUrl + if url == "" { + url = "ldap://ldap" + } + ldapConfig := map[string]any{ + "binddn": bindDn, + "bindpass": bindPass, + "url": url, + } + _, err := vc.Logical().Write(path.Join(mountPath, "config"), ldapConfig) + require.NoError(err) +} + // MountPKI mounts the Vault PKI secret engine and initializes it by // generating a root certificate authority and creating a default role on // the mount. The root CA is returned. @@ -1038,9 +1189,12 @@ type TestVaultServer struct { serverCertBundle *testCertBundle clientCertBundle *testCertBundle + // Use any here instead of *dockertest.[Pool|Network|Resource] to avoid + // problems with dockertest not building for some netbsd architectures. pool any - vaultContainer any network any + vaultContainer any + ldapContainer any postgresContainer any } @@ -1115,3 +1269,39 @@ func (d *TestDatabase) ValidateCredential(t testing.TB, s *vault.Secret) error { } return nil } + +// TestLdapServer is returned from MountLdapServer and contains information +// about how an LDAP server was setup. It is primarily used to test +// Boundary <> Vault <> LDAP Server interactions. +type TestLdapServer struct { + BindDomain string + BindPass string + BindDn string + Url string +} + +// MountLdapServer creates an LDAP server as a Docker container, within a +// running Vault's Docker network. Additionally, it mounts and configures the +// LDAP secrets engine in the passed in Vault instance. +// +// Supported options: +// - WithBindPass. Controls the LDAP server's admin password. Defaults to +// "admin". +// - WithLdapRootDomain. Controls the LDAP server's root domain. Defaults to +// "example.org". +// +// Some roles are added to the LDAP server as well as configured within Vault: +// - scientists: A group of unique members: cn=scientists,dc=example,dc=org +// - einstein: A user belonging to the scientists group: cn=einstein,dc=example,dc=org +// - newton: Another user beloging to the scientists group: cn=newton,dc=example,dc=org +// +// Note that the domain components (dc) will change accordingly if WithDomain is +// passed in. +// +// Dynamic roles are also setup using LDIF templates. These roles trigger Vault +// to create users on-demand and manage their lifecycle. All dynamic users are +// added to the "scientists" group. +func MountLdapServer(t testing.TB, v *TestVaultServer, opt ...TestOption) *TestLdapServer { + t.Helper() + return mountLdapServer(t, v, opt...) +} diff --git a/internal/proto/controller/storage/credential/vault/store/v1/vault.proto b/internal/proto/controller/storage/credential/vault/store/v1/vault.proto index a549c625a..25a3b02d9 100644 --- a/internal/proto/controller/storage/credential/vault/store/v1/vault.proto +++ b/internal/proto/controller/storage/credential/vault/store/v1/vault.proto @@ -535,3 +535,52 @@ message SshPrivateKeyOverride { // @inject_tag: `gorm:"default:null"` string private_key_passphrase_attribute = 4; } + +message LdapCredentialLibrary { + // public_id is a surrogate key suitable for use in a public API. + // @inject_tag: `gorm:"primary_key"` + string public_id = 1; + + // create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 2; + + // update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 3; + + // name is optional. If set, it must be unique within project_id. + // @inject_tag: `gorm:"default:null"` + string name = 4 [(custom_options.v1.mask_mapping) = { + this: "Name" + that: "name" + }]; + + // description is optional. + // @inject_tag: `gorm:"default:null"` + string description = 5 [(custom_options.v1.mask_mapping) = { + this: "Description" + that: "description" + }]; + + // store_id of the owning vault credential store. + // It must be set. + // @inject_tag: `gorm:"not_null"` + string store_id = 6; + + // version allows optimistic locking of the resource. + // @inject_tag: `gorm:"default:null"` + uint32 version = 7; + + // vault_path is the path in Vault to request credentials from. + // It must be set. + // @inject_tag: `gorm:"not_null"` + string vault_path = 8 [(custom_options.v1.mask_mapping) = { + this: "VaultPath" + that: "attributes.path" + }]; + + // credential_type is always ssh_certificate + // @inject_tag: `gorm:"default:null"` + string credential_type = 9; +}