Add client daemon repo, server, and cmd

The commands added are:
boundary daemon start
boundary daemon stop
boundary daemon add-token
boundary daemon status
boundary search

If these actions are performed on a platform that doesn't support sqlite
natively in go an error is returned.

Besides some hooks into the cmd package all the significant logic
resides in internal/clientcache

feat (cmds/authenticate): output the token name when storing token

feat: update go-dbw which now uses pure go sqlite driver

refact (base.Command): export FlagOutputCurlString

Exporting FlagOutputCurlString allows other commands that embed
the base.Command to access the flag as needed

feat: add a cache Repository to manage cache storage

feat: add a "cache" CLI command

This new command will support both managing and searching the
local CLI cache daemon.

feat: start cache daemon as needed when issue CLI commands

Move files to new directories and add persona table (#3526)

* Add persona table and move files to new directories

Client daemon now uses unix sockets and update pathing (#3535)

* Add unix socket listen and dialers with file permissions applied

* Update the pathing to match RFC

Cache refreshing now uses all stored personas. (#3545)

Updating headers for LLB files

Decouple daemon and cache server (#3566)

* Separate daemon from server

* Add persona handler to daemon server (#3579)

Moved search to use mql and added tests for the search command (#3589)

* search uses hashicorp/mql

Windows support for client side daemon (#3618)

* exec.Command for background process

* Log to stderr when in foreground and file always

* Use pid files across platform for start and stop

Requests with version info must match the daemon's version (#3640)

* requests with version info must match daemons

Update dependencies and breakout cgo/pure go sqlite drivers  (#3657)

upgrade go-dbw deps and add cgo build tags

Enable graceful shutdown in windows (#3695)

* Enable graceful shutdown in windows

Co-authored-by: Jim <jlambert@hashicorp.com>

Targets delete when a persona changes address or user id (#3673)

* Handle cache refresh in repo

Add sessions to the client side cache (#3682)

* Add sessions to repository

* Search command supports sessions

* adding session related tests to db and refresh

Change cache schema from personas to tokens+users (#3737)

* Change from personas to tokens+users

* Separate keyring tokens from auth tokens

Add support for keyringless auth tokens (#3765)

* Add support for keyringless auth tokens

Remove expired auth tokens from cache (#3768)

* Remove expired auth tokens from cache

Complete the TODO of checking for inflight tx (#3779)

Delete tokens that the controller says are invalid (#3782)

fix search test with boundary token check (#3799)

Collect resource info in repository_<resource>s.go (#3769)

* Collect resource info in repository_<resource>s.go

* Move search logic into the domain (#3794)

Daemon and search restricted to supported platforms (#3801)

* Daemon and search restricted to supported platforms

Reorganize the clientcache packages (#3814)

* Refactor for self contained domain logic

* Put clientcache stuff under internal
This commit is contained in:
Jim authored and Johan Brandhorst-Satzkorn committed 2024-01-05 07:43:20 -08:00
1 parent 6ba3676c37
commit fba9d1cdf1
71 files changed
+8980 -972

No files matched your search

+38 -12
View File
@@ -16,6 +16,7 @@ import (
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -57,7 +58,12 @@ type Config struct {
// HttpClient.
Addr string
// Token is the client token that reuslts from authentication and can be
// Set when the Addr is a unix socket. The parsing of this field takes
// a filename approach instead of a URL approach since parsing file names
// as a URL may error out on some platforms.
socketAddr string
// Token is the client token that results from authentication and can be
// used to make calls into Boundary
Token string
@@ -254,6 +260,16 @@ func (c *Config) ConfigureTLS() error {
// This also removes any trailing "/v1"; we'll use that in our commands so we
// don't require it from users.
func (c *Config) setAddr(addr string) error {
if strings.HasPrefix(addr, "unix://") {
c.socketAddr = strings.TrimPrefix(addr, "unix://")
c.socketAddr = filepath.Clean(c.socketAddr)
// Update the specified address to the cleaned up version
c.Addr = fmt.Sprintf("unix://%s", c.socketAddr)
return nil
}
c.socketAddr = ""
u, err := url.Parse(addr)
if err != nil {
return fmt.Errorf("error parsing address: %w", err)
@@ -582,6 +598,7 @@ func (c *Client) Clone() *Client {
config := c.config
newConfig := &Config{
socketAddr: config.socketAddr,
Addr: config.Addr,
Token: config.Token,
RecoveryKmsWrapper: config.RecoveryKmsWrapper,
@@ -645,31 +662,29 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod
}
c.modifyLock.RLock()
addr := c.config.Addr
srvLookup := c.config.SRVLookup
token := c.config.Token
httpClient := c.config.HttpClient
headers := copyHeaders(c.config.Headers)
c.modifyLock.RUnlock()
u, err := url.Parse(addr)
if err != nil {
return nil, err
}
if strings.HasPrefix(addr, "unix://") {
socket := strings.TrimPrefix(addr, "unix://")
var hostHeader string
var u *url.URL
switch {
case c.config.socketAddr != "":
transport := httpClient.Transport.(*http.Transport)
transport.DialContext = func(context.Context, string, string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", socket)
return dialer.DialContext(ctx, "unix", c.config.socketAddr)
}
// Since the address points to a unix domain socket, the scheme in the
// *URL would be set to `unix`. The *URL in the client is expected to
// be pointing to the protocol used in the application layer and not to
// the transport layer. Hence, setting the fields accordingly.
u = &url.URL{}
u.Scheme = "http"
u.Host = c.config.socketAddr
u.Path = ""
// Go 1.21.0 introduced strict host header validation for clients.
@@ -680,7 +695,17 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod
// that isn't the actual unix domain socket address. Following
// Docker's lead (https://github.com/moby/moby/pull/45942),
// use a localhost TLD.
u.Host = "api.boundary.localhost"
hostHeader = "api.boundary.localhost"
case c.config.Addr != "":
addr := c.config.Addr
var err error
u, err = url.Parse(addr)
if err != nil {
return nil, err
}
hostHeader = u.Host
default:
return nil, errors.New("no valid address discovered")
}
host := u.Host
@@ -707,8 +732,9 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod
Host: host,
Path: path.Join(u.Path, "/v1/", requestPath),
},
Host: u.Host,
Host: hostHeader,
}
req.Header = headers
req.Header.Set("authorization", "Bearer "+token)
req.Header.Set("content-type", "application/json")
+48 -39
View File
@@ -11,60 +11,70 @@ import (
func TestConfigSetAddress(t *testing.T) {
type test struct {
name string
input string
address string
err string
name string
input string
address string
socketAddr string
}
tests := []test{
{
"bare",
"http://127.0.0.1:9200",
"http://127.0.0.1:9200",
"",
name: "bare",
input: "http://127.0.0.1:9200",
address: "http://127.0.0.1:9200",
},
{
"bare with version",
"http://127.0.0.1:9200/v1",
"http://127.0.0.1:9200",
"",
name: "bare with version",
input: "http://127.0.0.1:9200/v1",
address: "http://127.0.0.1:9200",
},
{
"bare with version and trailing slash",
"http://127.0.0.1:9200/v1/",
"http://127.0.0.1:9200",
"",
name: "bare with version and trailing slash",
input: "http://127.0.0.1:9200/v1/",
address: "http://127.0.0.1:9200",
},
{
"valid top level scope",
"http://127.0.0.1:9200/v1/scopes",
"http://127.0.0.1:9200",
"",
name: "valid top level scope",
input: "http://127.0.0.1:9200/v1/scopes",
address: "http://127.0.0.1:9200",
},
{
"valid scope",
"http://127.0.0.1:9200/v1/scopes/scopeid",
"http://127.0.0.1:9200",
"",
name: "valid scope",
input: "http://127.0.0.1:9200/v1/scopes/scopeid",
address: "http://127.0.0.1:9200",
},
{
"longer path project",
"http://127.0.0.1:9200/v1/auth-methods",
"http://127.0.0.1:9200",
"",
name: "longer path project",
input: "http://127.0.0.1:9200/v1/auth-methods",
address: "http://127.0.0.1:9200",
},
{
"valid project",
"http://127.0.0.1:9200/my-install",
"http://127.0.0.1:9200/my-install",
"",
name: "valid project",
input: "http://127.0.0.1:9200/my-install",
address: "http://127.0.0.1:9200/my-install",
},
{
"valid project path containing v1",
"http://127.0.0.1:9200/randomPathHasv1InIt",
"http://127.0.0.1:9200/randomPathHasv1InIt",
"",
name: "valid project path containing v1",
input: "http://127.0.0.1:9200/randomPathHasv1InIt",
address: "http://127.0.0.1:9200/randomPathHasv1InIt",
},
{
name: "unix socket with linux pathing",
input: "unix:///home/username/.boundary/socketdir/boundary.sock",
address: "unix:///home/username/.boundary/socketdir/boundary.sock",
socketAddr: "/home/username/.boundary/socketdir/boundary.sock",
},
{
name: "unix socket with weird linux pathing",
input: "unix:///home/username/.boundary/something/../socketdir/boundary.sock",
address: "unix:///home/username/.boundary/socketdir/boundary.sock",
socketAddr: "/home/username/.boundary/socketdir/boundary.sock",
},
{
name: "unix socket with windows pathing",
input: "unix://C:\\Users\\Admin\\AppData\\boundary.sock",
address: "unix://C:\\Users\\Admin\\AppData\\boundary.sock",
socketAddr: "C:\\Users\\Admin\\AppData\\boundary.sock",
},
}
@@ -72,10 +82,9 @@ func TestConfigSetAddress(t *testing.T) {
t.Run(v.name, func(t *testing.T) {
var c Config
err := c.setAddr(v.input)
if err != nil {
assert.Equal(t, v.err, err.Error())
}
assert.NoError(t, err)
assert.Equal(t, v.address, c.Addr)
assert.Equal(t, v.socketAddr, c.socketAddr)
})
}
}
+23 -6
View File
@@ -88,8 +88,9 @@ require github.com/hashicorp/go-dbw v0.1.1
require (
github.com/DATA-DOG/go-sqlmock v1.5.0
github.com/cenkalti/backoff/v4 v4.2.1
github.com/creack/pty v1.1.20
github.com/cenkalti/backoff/v4 v4.2.0
github.com/creack/pty v1.1.18
github.com/glebarez/sqlite v1.9.0
github.com/golang/protobuf v1.5.3
github.com/hashicorp/cap/ldap v0.0.0-20231012003312-273118a6e3b8
github.com/hashicorp/dbassert v0.0.0-20231012105025-1bc1bd88e22b
@@ -102,12 +103,30 @@ require (
github.com/kelseyhightower/envconfig v1.4.0
github.com/miekg/dns v1.1.56
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a
github.com/mitchellh/go-homedir v1.1.0
github.com/sevlyar/go-daemon v0.1.6
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
golang.org/x/net v0.17.0
google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405
gopkg.in/square/go-jose.v2 v2.6.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-sqlite3 v2.0.1+incompatible // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
require (
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
gorm.io/driver/sqlite v1.5.4 // indirect
)
require (
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/AlecAivazis/survey/v2 v2.3.2 // indirect
@@ -148,6 +167,7 @@ require (
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5 // indirect
github.com/hashicorp/go-plugin v1.5.2 // indirect
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 // indirect
github.com/hashicorp/mql v0.0.0-20230814234610-74af33a21d7f
github.com/hashicorp/vault/sdk v0.3.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
@@ -160,16 +180,14 @@ require (
github.com/jackc/pgtype v1.14.0 // indirect
github.com/jefferai/go-libsecret v0.0.0-20210525195240-b53481abef97 // indirect
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f // indirect
github.com/jinzhu/gorm v1.9.12 // indirect
github.com/jinzhu/gorm v1.9.16 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v2.0.1+incompatible // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/term v0.5.0 // indirect
@@ -202,5 +220,4 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/sqlite v1.5.4 // indirect
)
+38 -7
View File
@@ -26,6 +26,7 @@ github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nB
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -34,6 +35,7 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0=
github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA=
@@ -61,8 +63,8 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c=
github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
@@ -90,8 +92,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.20 h1:VIPb/a2s17qNeQgDnkfZC35RScx+blkKF8GV68n80J4=
github.com/creack/pty v1.1.20/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
@@ -113,6 +115,8 @@ github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -138,6 +142,10 @@ github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0X
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
@@ -152,7 +160,7 @@ github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8V
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
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-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -205,6 +213,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
@@ -311,6 +321,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/mql v0.0.0-20230814234610-74af33a21d7f h1:hpvyk8Al2vfAtoqDOPJg9Z8WKMfpYcsDG85TEELbgmM=
github.com/hashicorp/mql v0.0.0-20230814234610-74af33a21d7f/go.mod h1:pSghNOmPNBD9/Bvx0M4EC2JB/mEVA6qlB9IKpNCiqOA=
github.com/hashicorp/nodeenrollment v0.2.9 h1:pFdMjd6VqDpqWtzr2+p//7DHKKPwxmbNoENCKCOYuvs=
github.com/hashicorp/nodeenrollment v0.2.9/go.mod h1:7lR/2H0cHO9ZsEQj7ixoaPYB7vNu5yP+8jyoCZtHeAY=
github.com/hashicorp/vault/api v1.10.0 h1:/US7sIjWN6Imp4o/Rj1Ce2Nr5bki/AXi9vAW3p2tOJQ=
@@ -396,8 +408,8 @@ github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgf
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/jimlambrt/gldap v0.1.9 h1:OPIRGQ/zdjKNLZYgLhNq1B6kMSB0aFmfgssWsOO0Brw=
github.com/jimlambrt/gldap v0.1.9/go.mod h1:wQXacI2If7+C8z/IaTIf6Sbb+tqgFoqzujN2AaGzyck=
github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q=
github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs=
github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -408,6 +420,8 @@ github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgb
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
@@ -455,6 +469,7 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@@ -558,6 +573,9 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -578,6 +596,8 @@ github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sethvargo/go-diceware v0.3.0 h1:UVVEfmN/uF50JfWAN7nbY6CiAlp5xeSx+5U0lWKkMCQ=
github.com/sethvargo/go-diceware v0.3.0/go.mod h1:lH5Q/oSPMivseNdhMERAC7Ti5oOPqsaVddU1BcN1CY0=
github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs=
github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
@@ -707,6 +727,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -719,7 +740,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/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=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -920,6 +943,14 @@ gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48=
mvdan.cc/gofumpt v0.5.0 h1:0EQ+Z56k8tXjj/6TQD25BFNKQXpCvT0rnansIc7Ug5E=
mvdan.cc/gofumpt v0.5.0/go.mod h1:HBeVDtMKRZpXyxFciAirzdKklDlGu8aAy1wEbH5Y9js=
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"os"
"strings"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/version"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
var (
_ cli.Command = (*AddTokenCommand)(nil)
_ cli.CommandAutocomplete = (*AddTokenCommand)(nil)
)
type AddTokenCommand struct {
*base.Command
}
func (c *AddTokenCommand) Synopsis() string {
return "Add an auth token to a running boundary daemon"
}
func (c *AddTokenCommand) Help() string {
helpText := `
Usage: boundary daemon add-token [options]
Add an auth token to the daemon:
$ boundary daemon add-token
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *AddTokenCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetClient | base.FlagSetOutputFormat)
return set
}
func (c *AddTokenCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *AddTokenCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *AddTokenCommand) Run(args []string) int {
ctx := c.Context
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
apiErr, err := c.Add(ctx)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
if apiErr != nil {
c.PrintApiError(apiErr, "Error from daemon when adding a token")
return base.CommandApiError
}
return base.CommandSuccess
}
func (c *AddTokenCommand) Add(ctx context.Context) (*api.Error, error) {
const op = "daemon.(AddTokenCommand).Add"
client, err := c.Client()
if err != nil {
return nil, err
}
keyringType, tokenName, err := c.DiscoverKeyringTokenInfo()
if err != nil {
return nil, err
}
pa := daemon.UpsertTokenRequest{
BoundaryAddr: client.Addr(),
}
switch keyringType {
case "", base.NoneKeyring:
keyringType = base.NoneKeyring
token := client.Token()
if parts := strings.SplitN(token, "_", 4); len(parts) == 3 {
pa.AuthTokenId = strings.Join(parts[:2], "_")
} else {
return nil, errors.New(ctx, errors.InvalidParameter, op, "found auth token is not in the proper format")
}
pa.AuthToken = token
default:
at := c.ReadTokenFromKeyring(keyringType, tokenName)
if at == nil {
return nil, errors.New(ctx, errors.Conflict, op, "no auth token available to send to daemon")
}
pa.Keyring = &daemon.KeyringToken{
KeyringType: keyringType,
TokenName: tokenName,
}
pa.AuthTokenId = at.Id
}
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return nil, err
}
return addToken(ctx, dotPath, &pa)
}
func addToken(ctx context.Context, daemonPath string, p *daemon.UpsertTokenRequest) (*api.Error, error) {
const op = "daemon.addToken"
client, err := api.NewClient(nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
addr := daemon.SocketAddress(daemonPath)
_, err = os.Stat(strings.TrimPrefix(addr, "unix://"))
if strings.HasPrefix(addr, "unix://") && err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if err := client.SetAddr(addr); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
// Because this is using the real lib it can pick up from stored locations
// like the system keychain. Explicitly clear the token for now
client.SetToken("")
req, err := client.NewRequest(ctx, "POST", "/tokens", p)
if err != nil {
return nil, err
}
req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber())
resp, err := client.Do(req)
if err != nil {
return nil, err
}
return resp.Decode(&struct{}{})
}
@@ -0,0 +1,73 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
)
// CommandWrapper starts the boundary daemon after the command was Run and attempts
// to send the current persona to any running daemon.
type CommandWrapper struct {
cli.Command
ui cli.Ui
}
// Wrap returns a cli.CommandFactory that returns a command wrapped in the CommandWrapper.
func Wrap(ui cli.Ui, wrapped cli.Command) cli.CommandFactory {
return func() (cli.Command, error) {
return &CommandWrapper{
Command: wrapped,
ui: ui,
}, nil
}
}
// Run runs the wrapped command and then attempts to start the boundary daemon and send
// the current persona
func (w *CommandWrapper) Run(args []string) int {
r := w.Command.Run(args)
ctx := context.Background()
if w.startDaemon(ctx) {
w.addPersonaInCache(ctx)
}
return r
}
// startDaemon attempts to start a daemon and returns true if we have attempted to start
// the daemon and either it was successful or it was already running.
func (w *CommandWrapper) startDaemon(ctx context.Context) bool {
cmdName, err := os.Executable()
if err != nil {
w.ui.Error(fmt.Sprintf("unable to find boundary binary for daemon startup: %s", err.Error()))
return false
}
var stdErr bytes.Buffer
cmd := exec.Command(cmdName, "daemon", "start", "-background")
cmd.Stderr = &stdErr
// We use Run here instead of Start because the command spawns off a subprocess and returns.
// We do not want to send the request to add a persona to the cache until we know the daemon
// has started up.
err = cmd.Run()
return err == nil || strings.Contains(stdErr.String(), "already running")
}
// addPersonaInCache runs AddPersonaCommand
func (w *CommandWrapper) addPersonaInCache(ctx context.Context) bool {
c := AddTokenCommand{Command: base.NewCommand(w.ui)}
c.Flags()
apiErr, err := c.Add(ctx)
return err == nil && apiErr == nil
}
@@ -0,0 +1,69 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
// +build !windows
package daemon
import (
"context"
stderrors "errors"
"os"
"github.com/hashicorp/boundary/internal/errors"
"github.com/sevlyar/go-daemon"
)
func writePidFile(ctx context.Context, pidFile string) (pidCleanup, error) {
const op = "daemon.writePidFile"
// Determine if we should clean up the file after we are done or if
// it should stick around in the case of lock aquision error since this
// file didn't create it.
var pidExists bool
_, err := os.Stat(pidFile)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return noopPidCleanup, errors.Wrap(ctx, err, op)
}
if err == nil {
pidExists = true
}
f, err := os.OpenFile(pidFile, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return noopPidCleanup, errors.Wrap(ctx, err, op, errors.WithMsg("opening file"))
}
closeAndDeleteFileFn := func() error {
err := f.Close()
if !pidExists {
err = stderrors.Join(err, os.Remove(pidFile))
}
return err
}
l := daemon.NewLockFile(f)
if err := l.Lock(); err != nil {
return closeAndDeleteFileFn, errors.Wrap(ctx, err, op)
}
// Now that we have aquired the lock and verified we own the pid file
// we can remove it always when cleaning up.
unlockAndCleanFn := func() error {
return l.Remove()
}
if err := l.WritePid(); err != nil {
return unlockAndCleanFn, errors.Wrap(ctx, err, op)
}
return unlockAndCleanFn, nil
}
func pidFileInUse(ctx context.Context, pidFile string) (*os.Process, error) {
const op = "daemon.pidFileInUse"
if pidFile == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "pid filename is empty")
}
proc, err := (&daemon.Context{PidFileName: pidFile}).Search()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
return proc, nil
}
@@ -0,0 +1,61 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPidInUse(t *testing.T) {
ctx := context.Background()
dotPath := t.TempDir()
pidPath := filepath.Join(dotPath, "boundary.pid")
used, err := pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.Nil(t, used)
workingPidCleanup1, err := writePidFile(ctx, pidPath)
assert.NoError(t, err)
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
failingPidCleanup, err := writePidFile(ctx, pidPath)
assert.Error(t, err)
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
assert.NoError(t, failingPidCleanup())
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
assert.NoError(t, workingPidCleanup1())
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.Nil(t, used)
workingPidCleanup2, err := writePidFile(ctx, pidPath)
assert.NoError(t, err)
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
assert.NoError(t, workingPidCleanup2())
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.Nil(t, used)
}
@@ -0,0 +1,100 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
// +build windows
package daemon
import (
"bytes"
"context"
stderrors "errors"
"fmt"
"io"
"os"
"strings"
"github.com/hashicorp/boundary/internal/errors"
"golang.org/x/sys/windows"
)
func writePidFile(ctx context.Context, pidFile string) (pidCleanup, error) {
const op = "daemon.writePidFile"
// Create the file for writing, and set shared read so following processes
// cannot open this file for writing.
fd, err := windows.CreateFile(&(windows.StringToUTF16(pidFile)[0]), windows.GENERIC_WRITE,
windows.FILE_SHARE_READ, nil, windows.OPEN_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return noopPidCleanup, errors.Wrap(ctx, err, op)
}
cleanup := func() error {
var err error
if inErr := windows.CloseHandle(fd); inErr != nil {
err = stderrors.Join(err, errors.Wrap(ctx, inErr, op, errors.WithMsg("handler close")))
}
if inErr := windows.DeleteFile(&(windows.StringToUTF16(pidFile)[0])); inErr != nil {
err = stderrors.Join(err, errors.Wrap(ctx, inErr, op, errors.WithMsg("removing file")))
}
return err
}
if _, err := windows.Seek(fd, 0, windows.FILE_BEGIN); err != nil {
return cleanup, errors.Wrap(ctx, err, op)
}
b := bytes.NewBuffer(nil)
if _, err := fmt.Fprint(b, os.Getpid()); err != nil {
return cleanup, errors.Wrap(ctx, err, op, errors.WithMsg("writing file buffer"))
}
var fileLen int
if fileLen, err = windows.Write(fd, b.Bytes()); err != nil {
return cleanup, errors.Wrap(ctx, err, op, errors.WithMsg("writing buffer to file"))
}
if err = windows.Ftruncate(fd, int64(fileLen)); err != nil {
return cleanup, errors.Wrap(ctx, err, op)
}
return cleanup, windows.Fsync(fd)
}
func pidFileInUse(ctx context.Context, pidFile string) (*os.Process, error) {
const op = "daemon.pidFileInUse"
if pidFile == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "pid filename is empty")
}
var err error
var file *os.File
if file, err = os.OpenFile(pidFile, os.O_RDONLY, 0o640); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("OpenFile"))
}
if file == nil {
return nil, nil
}
defer func() {
file.Close()
}()
if _, err = file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
var pid int
_, err = fmt.Fscan(file, &pid)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Fscan"))
}
p, err := os.FindProcess(pid)
if err != nil {
if strings.Contains(err.Error(), "The parameter is incorrect") {
return nil, errors.New(ctx, errors.NotFound, op, "cannot find process")
}
// we failed to get the process for whatever reason
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("FindProcess %d", pid))
}
if p == nil {
return nil, errors.New(ctx, errors.NotFound, op, "cannot find process")
}
return p, nil
}
+273
View File
@@ -0,0 +1,273 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/mitchellh/cli"
"github.com/mitchellh/go-homedir"
"github.com/posener/complete"
)
const (
dotDirname = ".boundary"
pidFileName = "cache.pid"
logFileName = "cache.log"
// Mark of process as having been started in the background
backgroundEnvName = "_BOUNDARY_DAEMON_BACKGROUND"
backgroundEnvVal = "1"
)
var (
_ cli.Command = (*StartCommand)(nil)
_ cli.CommandAutocomplete = (*StartCommand)(nil)
)
type server interface {
setupLogging(context.Context, io.Writer) error
serve(context.Context, daemon.Commander, net.Listener) error
shutdown() error
}
type StartCommand struct {
*base.Command
flagRefreshIntervalSeconds int64
flagDatabaseUrl string
flagLogLevel string
flagLogFormat string
flagStoreDebug bool
flagBackground bool
}
func (c *StartCommand) Synopsis() string {
return "Start a Boundary daemon"
}
func (c *StartCommand) Help() string {
helpText := `
Usage: boundary daemon start [options]
Start a daemon:
$ boundary daemon start
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *StartCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient)
f := set.NewFlagSet("Command Options")
f.StringVar(&base.StringVar{
Name: "log-level",
Target: &c.flagLogLevel,
EnvVar: "BOUNDARY_LOG_LEVEL",
Completion: complete.PredictSet("trace", "debug", "info", "warn", "err"),
Usage: "Log verbosity level, mostly as a fallback for events. Supported values (in order of more detail to less) are " +
"\"trace\", \"debug\", \"info\", \"warn\", and \"err\".",
})
f.StringVar(&base.StringVar{
Name: "log-format",
Target: &c.flagLogFormat,
Completion: complete.PredictSet("standard", "json"),
Usage: `Log format, mostly as a fallback for events. Supported values are "standard" and "json".`,
})
f.StringVar(&base.StringVar{
Name: "database-url",
Target: &c.flagDatabaseUrl,
Usage: `If set, specifies the URL used to connect to the sqlite database (store) for caching. This can refer to a file on disk (file://) from which a URL will be read; an env var (env://) from which the URL will be read; or a direct database URL.`,
})
f.Int64Var(&base.Int64Var{
Name: "refresh-interval-seconds",
Target: &c.flagRefreshIntervalSeconds,
Usage: `If set, specifies the number of seconds between cache refreshes. Default: 5 minutes`,
Aliases: []string{"r"},
Default: daemon.DefaultRefreshIntervalSeconds,
})
f.BoolVar(&base.BoolVar{
Name: "store-debug",
Target: &c.flagStoreDebug,
Default: false,
Usage: `Turn on store debugging`,
Aliases: []string{"d"},
})
f.BoolVar(&base.BoolVar{
Name: "background",
Target: &c.flagBackground,
Default: false,
Usage: `Turn on store debugging`,
})
return set
}
func (c *StartCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *StartCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *StartCommand) Run(args []string) int {
const op = "daemon.(StartCommand).Run"
ctx, cancel := context.WithCancel(c.Context)
defer cancel()
var err error
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
dotDir, err := DefaultDotDirectory(ctx)
if err != nil {
return base.CommandCliError
}
continueRun, writers, cleanup, err := makeBackground(ctx, dotDir, c.flagBackground)
defer func() {
if cleanup != nil {
cleanup()
}
}()
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
if !continueRun {
return base.CommandSuccess
}
// TODO: print something out for the spawner to consume in case they can easily
// report if the daemon started or not.
logFilePath := filepath.Join(dotDir, logFileName)
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
defer logFile.Close()
if _, err := logFile.Seek(0, io.SeekEnd); err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
writers = append(writers, logFile)
cfg := &daemon.Config{
ContextCancel: cancel,
RefreshIntervalSeconds: c.flagRefreshIntervalSeconds,
DatabaseUrl: c.flagDatabaseUrl,
StoreDebug: c.flagStoreDebug,
LogLevel: c.flagLogLevel,
LogFormat: c.flagLogFormat,
LogWriter: io.MultiWriter(writers...),
DotDirectory: dotDir,
}
srv, err := daemon.New(ctx, cfg)
if err != nil {
c.UI.Error(err.Error())
return base.CommandUserError
}
var srvErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
srvErr = srv.Serve(ctx, c)
}()
// This is a blocking call. We rely on the c.ShutdownCh to cancel this
// context when sigterm or sigint is received.
<-ctx.Done()
if err := srv.Shutdown(ctx); err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
wg.Wait()
if srvErr != nil {
c.PrintCliError(srvErr)
return base.CommandCliError
}
return base.CommandSuccess
}
// DefaultDotDirectory returns the default path to the boundary dot directory.
func DefaultDotDirectory(ctx context.Context) (string, error) {
const op = "daemon.DefaultDotDirectory"
homeDir, err := homedir.Dir()
if err != nil {
return "", errors.Wrap(ctx, err, op)
}
return filepath.Join(homeDir, dotDirname), nil
}
func makeBackground(ctx context.Context, dotDir string, runBackgroundFlag bool) (bool, []io.Writer, pidCleanup, error) {
const op = "daemon.makeBackground"
writers := []io.Writer{}
pidPath := filepath.Join(dotDir, pidFileName)
if running, err := pidFileInUse(ctx, pidPath); running != nil {
return false, writers, noopPidCleanup, errors.New(ctx, errors.Conflict, op, "daemon already running")
} else if err != nil && !errors.Match(errors.T(errors.NotFound), err) {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
if !runBackgroundFlag && os.Getenv(backgroundEnvName) != backgroundEnvVal {
writers = append(writers, os.Stderr)
}
if !runBackgroundFlag || os.Getenv(backgroundEnvName) == backgroundEnvVal {
// We are either already running in the background or background was
// not requested. Write the pid file and continue.
cleanup, err := writePidFile(ctx, pidPath)
if err != nil {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
return true, writers, cleanup, nil
}
absPath, err := os.Executable()
if err != nil {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
env := os.Environ()
env = append(env, fmt.Sprintf("%s=%s", backgroundEnvName, backgroundEnvVal))
cmd := exec.Command(absPath, "daemon", "start")
cmd.Env = env
if err = cmd.Start(); err != nil {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
// TODO: Read the output from the child process for a brief time
// to see if we can identify any errors that might arise.
return false, writers, noopPidCleanup, nil
}
type pidCleanup func() error
var noopPidCleanup pidCleanup = func() error { return nil }
+71
View File
@@ -0,0 +1,71 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
stderr "errors"
"strings"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/posener/complete"
)
type StopCommand struct {
*base.Command
}
func (c *StopCommand) Synopsis() string {
return "Stop a Boundary daemon"
}
func (c *StopCommand) Help() string {
helpText := `
Usage: boundary daemon stop
Stop a daemon:
$ boundary daemon stop
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *StopCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient)
return set
}
func (c *StopCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *StopCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *StopCommand) Run(args []string) int {
ctx, cancel := context.WithCancel(c.Context)
c.Context = ctx
c.ContextCancel = cancel
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
if err := c.stop(c.Context); err != nil {
if errors.Match(errors.T(errors.NotFound), err) {
c.PrintCliError(stderr.New("deamon process is not running"))
} else {
c.PrintCliError(err)
}
return base.CommandUserError
}
return base.CommandSuccess
}
@@ -0,0 +1,42 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
// +build !windows
package daemon
import (
"context"
"path/filepath"
"syscall"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
)
// stop will send a term signal to the daemon to shut down.
func (s *StopCommand) stop(ctx context.Context) error {
const op = "daemon.(StopCommand).stop"
switch {
case util.IsNil(ctx):
return errors.New(ctx, errors.InvalidParameter, op, "context is missing")
}
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return errors.Wrap(ctx, err, op)
}
pidPath := filepath.Join(dotPath, pidFileName)
p, err := pidFileInUse(ctx, pidPath)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if p == nil {
return errors.New(ctx, errors.NotFound, op, "daemon not running")
}
if err := p.Signal(syscall.SIGTERM); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("sending sigterm to process"))
}
return nil
}
@@ -0,0 +1,94 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
// +build windows
package daemon
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
"github.com/hashicorp/boundary/version"
)
// stop will send a term signal to the daemon to shut down.
func (s *StopCommand) stop(ctx context.Context) error {
const op = "daemon.(StopCommand).stop"
switch {
case util.IsNil(ctx):
return errors.New(ctx, errors.InvalidParameter, op, "context is missing")
}
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return errors.Wrap(ctx, err, op)
}
apiErr, err := stopThroughHandler(ctx, dotPath)
switch {
case err != nil, apiErr != nil:
var errMsg string
if err != nil {
errMsg = err.Error()
} else if apiErr != nil {
errMsg = apiErr.Message
}
s.UI.Warn(fmt.Sprintf("failed stopping the daemon through the handler: %q, now killing the process", errMsg))
default:
// there wasn't an error stopping it through the handler. No need to
// force kill the process
return nil
}
pidPath := filepath.Join(dotPath, pidFileName)
p, err := pidFileInUse(ctx, pidPath)
if err != nil {
if os.IsNotExist(err) {
return errors.Wrap(ctx, err, op, errors.WithCode(errors.NotFound))
}
return errors.Wrap(ctx, err, op, errors.WithMsg("Unable to stop the daemon"))
}
if p == nil {
return errors.New(ctx, errors.NotFound, op, "daemon process was not found")
}
if err := p.Kill(); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("killing the daemon"))
}
return nil
}
func stopThroughHandler(ctx context.Context, dotPath string) (*api.Error, error) {
const op = "daemon.stopThroughHandler"
sockAddr := daemon.SocketAddress(dotPath)
client, err := api.NewClient(nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if err := client.SetAddr(sockAddr); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
// Because this is using the real lib it can pick up from stored locations
// like the system keychain. Explicitly clear the token for now
client.SetToken("")
req, err := client.NewRequest(ctx, "POST", "/stop", nil)
if err != nil {
return nil, err
}
req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber())
resp, err := client.Do(req)
if err != nil {
return nil, err
}
return resp.Decode(&struct{}{})
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
/*
Package cmd contains the command packages to the client cache. Adding
these commands to the boundary cli is the only integration between the boundary
binary and the client cache.
*/
package cmd
+335
View File
@@ -0,0 +1,335 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package search
import (
"context"
stderrors "errors"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
daemoncmd "github.com/hashicorp/boundary/internal/clientcache/cmd/daemon"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/version"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"golang.org/x/exp/slices"
)
var (
_ cli.Command = (*SearchCommand)(nil)
_ cli.CommandAutocomplete = (*SearchCommand)(nil)
supportedResourceTypes = []string{
"targets",
"sessions",
}
)
type SearchCommand struct {
*base.Command
flagNameStartsWith string
flagQuery string
flagResource string
}
func (c *SearchCommand) Synopsis() string {
return "Search resources in boundary"
}
func (c *SearchCommand) Help() string {
helpText := `
Usage: boundary search [options]
Search a boundary resource:
$ boundary search -resource targets
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *SearchCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetClient | base.FlagSetOutputFormat)
f := set.NewFlagSet("Command Options")
f.StringVar(&base.StringVar{
Name: "name-starts-with",
Target: &c.flagNameStartsWith,
Usage: `If set, specifies to search for a target that starts with`,
})
f.StringVar(&base.StringVar{
Name: "query",
Target: &c.flagQuery,
Usage: `If set, specifies the target search query`,
})
f.StringVar(&base.StringVar{
Name: "resource",
Target: &c.flagResource,
Usage: `Specifies the resource type to search over`,
Completion: complete.PredictSet(supportedResourceTypes...),
})
return set
}
func (c *SearchCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *SearchCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *SearchCommand) Run(args []string) int {
ctx := c.Context
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
switch {
case slices.Contains(supportedResourceTypes, c.flagResource):
case c.flagResource == "":
c.PrintCliError(stderrors.New("Resource is required but not passed in via -resource"))
return base.CommandUserError
default:
c.PrintCliError(stderrors.New("The value passed in with -resource is not currently supported in search"))
return base.CommandUserError
}
resp, err := c.Search(ctx)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
res := &daemon.SearchResult{}
apiErr, err := resp.Decode(res)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
if apiErr != nil {
c.PrintApiError(apiErr, "Error from daemon when performing search")
return base.CommandApiError
}
switch base.Format(c.UI) {
case "json":
c.UI.Output(resp.Body.String())
default:
switch {
case len(res.Targets) > 0:
c.UI.Output(printTargetListTable(res.Targets))
case len(res.Sessions) > 0:
c.UI.Output(printSessionListTable(res.Sessions))
default:
c.UI.Output("No items found")
}
}
return base.CommandSuccess
}
func (c *SearchCommand) Search(ctx context.Context) (*api.Response, error) {
keyringType, tokenName, err := c.DiscoverKeyringTokenInfo()
if err != nil {
return nil, err
}
tf := filterBy{
flagQuery: c.flagQuery,
resource: c.flagResource,
}
at := c.ReadTokenFromKeyring(keyringType, tokenName)
if at != nil {
tf.authTokenId = at.Id
}
dotPath, err := daemoncmd.DefaultDotDirectory(ctx)
if err != nil {
return nil, err
}
return search(ctx, dotPath, tf)
}
func search(ctx context.Context, daemonPath string, fb filterBy) (*api.Response, error) {
const op = "search.search"
client, err := api.NewClient(nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
addr := daemon.SocketAddress(daemonPath)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if _, err := os.Stat(strings.TrimPrefix(addr, "unix://")); strings.HasPrefix(addr, "unix://") && err == os.ErrNotExist {
return nil, errors.New(ctx, errors.Internal, op, "daemon unix socket is not setup")
}
if err := client.SetAddr(addr); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
req, err := client.NewRequest(ctx, "GET", "/search", nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("new client request error"))
}
req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber())
q := url.Values{}
q.Add("auth_token_id", fb.authTokenId)
q.Add("resource", fb.resource)
q.Add("query", fb.flagQuery)
req.URL.RawQuery = q.Encode()
resp, err := client.Do(req)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("client do failed"))
}
return resp, err
}
func printTargetListTable(items []*targets.Target) string {
if len(items) == 0 {
return "No targets found"
}
var output []string
output = []string{
"",
"Target information:",
}
for i, item := range items {
if i > 0 {
output = append(output, "")
}
if item.Id != "" {
output = append(output,
fmt.Sprintf(" ID: %s", item.Id),
)
} else {
output = append(output,
fmt.Sprintf(" ID: %s", "(not available)"),
)
}
if item.ScopeId != "" {
output = append(output,
fmt.Sprintf(" Scope ID: %s", item.ScopeId),
)
}
if item.Version > 0 {
output = append(output,
fmt.Sprintf(" Version: %d", item.Version),
)
}
if item.Type != "" {
output = append(output,
fmt.Sprintf(" Type: %s", item.Type),
)
}
if item.Name != "" {
output = append(output,
fmt.Sprintf(" Name: %s", item.Name),
)
}
if item.Description != "" {
output = append(output,
fmt.Sprintf(" Description: %s", item.Description),
)
}
if item.Address != "" {
output = append(output,
fmt.Sprintf(" Address: %s", item.Address),
)
}
if len(item.AuthorizedActions) > 0 {
output = append(output,
" Authorized Actions:",
base.WrapSlice(6, item.AuthorizedActions),
)
}
}
return base.WrapForHelpText(output)
}
func printSessionListTable(items []*sessions.Session) string {
if len(items) == 0 {
return "No sessions found"
}
var output []string
output = []string{
"",
"Session information:",
}
for i, item := range items {
if i > 0 {
output = append(output, "")
}
if item.Id != "" {
output = append(output,
fmt.Sprintf(" ID: %s", item.Id),
)
} else {
output = append(output,
fmt.Sprintf(" ID: %s", "(not available)"),
)
}
if item.ScopeId != "" {
output = append(output,
fmt.Sprintf(" Scope ID: %s", item.ScopeId),
)
}
if item.Status != "" {
output = append(output,
fmt.Sprintf(" Status: %s", item.Status),
)
}
if !item.CreatedTime.IsZero() {
output = append(output,
fmt.Sprintf(" Created Time: %s", item.CreatedTime.Local().Format(time.RFC1123)),
)
}
if !item.ExpirationTime.IsZero() {
output = append(output,
fmt.Sprintf(" Expiration Time: %s", item.ExpirationTime.Local().Format(time.RFC1123)),
)
}
if !item.UpdatedTime.IsZero() {
output = append(output,
fmt.Sprintf(" Updated Time: %s", item.UpdatedTime.Local().Format(time.RFC1123)),
)
}
if item.UserId != "" {
output = append(output,
fmt.Sprintf(" User ID: %s", item.UserId),
)
}
if item.TargetId != "" {
output = append(output,
fmt.Sprintf(" Target ID: %s", item.TargetId),
)
}
if len(item.AuthorizedActions) > 0 {
output = append(output,
" Authorized Actions:",
base.WrapSlice(6, item.AuthorizedActions),
)
}
}
return base.WrapForHelpText(output)
}
type filterBy struct {
flagQuery string
authTokenId string
resource string
}
@@ -0,0 +1,252 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package search
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testCommander struct {
t *testing.T
at *authtokens.AuthToken
}
func (r *testCommander) keyring() string {
return r.at.Id
}
func (r *testCommander) tokenName() string {
return r.at.Id
}
func (r *testCommander) Client(opt ...base.Option) (*api.Client, error) {
client, err := api.NewClient(nil)
require.NoError(r.t, err)
return client, nil
}
func (r *testCommander) DiscoverKeyringTokenInfo() (string, string, error) {
return r.keyring(), r.tokenName(), nil
}
func (r *testCommander) ReadTokenFromKeyring(k, a string) *authtokens.AuthToken {
return r.at
}
func TestSearch(t *testing.T) {
ctx := context.Background()
at := &authtokens.AuthToken{
Id: "at_1",
UserId: "user_1",
Token: "at_1_token",
ExpirationTime: time.Now().Add(time.Minute),
}
cmd := &testCommander{t: t, at: at}
boundaryTokenReaderFn := func(ctx context.Context, addr, authToken string) (*authtokens.AuthToken, error) {
if authToken == at.Token {
return at, nil
}
return nil, errors.New("test not found error")
}
srv := daemon.NewTestServer(t, cmd)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
srv.Serve(t, daemon.WithBoundaryTokenReaderFunc(ctx, boundaryTokenReaderFn))
}()
// Give the store some time to get initialized
time.Sleep(100 * time.Millisecond)
errorCases := []struct {
name string
fb filterBy
apiErrContains string
}{
{
name: "no resource",
fb: filterBy{
flagQuery: "name=name",
authTokenId: at.Id,
},
apiErrContains: "resource is a required field but was empty",
},
{
name: "bad resource",
fb: filterBy{
authTokenId: at.Id,
flagQuery: "name=name",
resource: "hosts",
},
apiErrContains: "provided resource is not a valid searchable resource",
},
{
name: "unknown auth token id",
fb: filterBy{
authTokenId: "unknown",
flagQuery: "description % tar",
resource: "targets",
},
apiErrContains: "Forbidden",
},
{
name: "unsupported column",
fb: filterBy{
authTokenId: at.Id,
flagQuery: "item % 'tar'",
resource: "targets",
},
apiErrContains: "invalid column \"item\"",
},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), tc.fb)
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, tc.apiErrContains)
})
}
t.Run("empty response from list", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.EqualValues(t, r, daemon.SearchResult{})
})
t.Run("empty response from query", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
flagQuery: "name=name",
resource: "targets",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.EqualValues(t, r, daemon.SearchResult{})
})
srv.AddResources(t, cmd.at, []*targets.Target{
{Id: "ttcp_1234567890", Name: "name1", Description: "description1"},
{Id: "ttcp_0987654321", Name: "name2", Description: "description2"},
}, []*sessions.Session{
{Id: "sess_1234567890", TargetId: "ttcp_1234567890", Status: "pending"},
{Id: "sess_0987654321", TargetId: "ttcp_0987654321", Status: "pending"},
}, boundaryTokenReaderFn)
t.Run("target response from list", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.Len(t, r.Targets, 2)
})
t.Run("full target response from query", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
flagQuery: "id % ttcp",
resource: "targets",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.Len(t, r.Targets, 2)
})
t.Run("partial target response from query", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
flagQuery: "id % ttcp_1234567890",
resource: "targets",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.Len(t, r.Sessions, 0)
assert.Len(t, r.Targets, 1)
})
t.Run("session response from list", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "sessions",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.Len(t, r.Targets, 0)
assert.Len(t, r.Sessions, 2)
})
t.Run("full session response from query", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
flagQuery: "id % sess",
resource: "sessions",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.Len(t, r.Sessions, 2)
})
t.Run("partial session response from query", func(t *testing.T) {
resp, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
flagQuery: "id % sess_1234567890",
resource: "sessions",
})
require.NoError(t, err)
r := daemon.SearchResult{}
apiErr, err := resp.Decode(&r)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, r)
assert.Len(t, r.Sessions, 1)
})
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
/*
Package clientcache contains the boundary client cache.
The client cache is a cache that runs on a client's machine and caches a subset
of boundary resources and makes them available for functionality like searching.
*/
package clientcache
+7
View File
@@ -0,0 +1,7 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
/*
Package cache contains the domain logic for the client cache.
*/
package cache
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"github.com/hashicorp/go-dbw"
)
type options struct {
withUpdateLastAccessedTime bool
withDbType dbw.DbType
withTargetRetrievalFunc TargetRetrievalFunc
withSessionRetrievalFunc SessionRetrievalFunc
}
// Option - how options are passed as args
type Option func(*options) error
func getDefaultOptions() options {
return options{
withDbType: dbw.Sqlite,
}
}
func getOpts(opt ...Option) (options, error) {
opts := getDefaultOptions()
for _, o := range opt {
if err := o(&opts); err != nil {
return opts, err
}
}
return opts, nil
}
// WithUpdateLastAccessedTime provides an option for updating the last access time
func WithUpdateLastAccessedTime(b bool) Option {
return func(o *options) error {
o.withUpdateLastAccessedTime = b
return nil
}
}
// WithTargetRetrievalFunc provides an option for specifying a targetRetrievalFunc
func WithTargetRetrievalFunc(fn TargetRetrievalFunc) Option {
return func(o *options) error {
o.withTargetRetrievalFunc = fn
return nil
}
}
// WithSessionRetrievalFunc provides an option for specifying a sessionRetrievalFunc
func WithSessionRetrievalFunc(fn SessionRetrievalFunc) Option {
return func(o *options) error {
o.withSessionRetrievalFunc = fn
return nil
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"testing"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/go-dbw"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetOpts(t *testing.T) {
t.Parallel()
t.Run("default", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
testOpts := options{
withDbType: dbw.Sqlite,
}
assert.Equal(t, opts, testOpts)
})
t.Run("WithUpdateLastAccessedTime", func(t *testing.T) {
opts, err := getOpts(WithUpdateLastAccessedTime(true))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withUpdateLastAccessedTime = true
assert.Equal(t, opts, testOpts)
})
t.Run("WithTargetRetrievalFunc", func(t *testing.T) {
var f TargetRetrievalFunc = func(ctx context.Context, keyringstring, tokenName string) ([]*targets.Target, error) { return nil, nil }
opts, err := getOpts(WithTargetRetrievalFunc(f))
require.NoError(t, err)
assert.NotNil(t, opts.withTargetRetrievalFunc)
opts.withTargetRetrievalFunc = nil
testOpts := getDefaultOptions()
assert.Equal(t, opts, testOpts)
})
t.Run("WithSessionRetrievalFunc", func(t *testing.T) {
var f SessionRetrievalFunc = func(ctx context.Context, keyringstring, tokenName string) ([]*sessions.Session, error) {
return nil, nil
}
opts, err := getOpts(WithSessionRetrievalFunc(f))
require.NoError(t, err)
assert.NotNil(t, opts.withSessionRetrievalFunc)
opts.withSessionRetrievalFunc = nil
testOpts := getDefaultOptions()
assert.Equal(t, opts, testOpts)
})
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
stderrors "errors"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/util"
)
type RefreshService struct {
repo *Repository
}
func NewRefreshService(ctx context.Context, r *Repository) (*RefreshService, error) {
return &RefreshService{repo: r}, nil
}
// cleanAndPickAuthTokens removes from the cache all auth tokens which are
// evicted from the cache or no longer stored in a keyring and returns the
// remaining ones.
// The returned auth tokens have not been validated against boundary
func (r *RefreshService) cleanAndPickAuthTokens(ctx context.Context, u *user) (map[AuthToken]string, error) {
const op = "cache.(RefreshService).cleanAndPickAuthTokens"
switch {
case util.IsNil(u):
return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil")
}
ret := make(map[AuthToken]string)
tokens, err := r.repo.listTokens(ctx, u)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
for _, t := range tokens {
keyringTokens, err := r.repo.listKeyringTokens(ctx, t)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("for user %v, auth token %q", u, t.Id))
}
for _, kt := range keyringTokens {
at := r.repo.tokenKeyringFn(kt.KeyringType, kt.TokenName)
switch {
case at == nil, at.Id != kt.AuthTokenId, at.UserId != t.UserId:
// delete the keyring token if the auth token in the keyring
// has changed since it was stored in the cache.
if err := r.repo.deleteKeyringToken(ctx, *kt); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
case at != nil:
_, err := r.repo.tokenReadFromBoundaryFn(ctx, u.Address, at.Token)
var apiErr *api.Error
switch {
case err != nil && api.ErrUnauthorized.Is(err):
if err := r.repo.deleteKeyringToken(ctx, *kt); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
continue
case err != nil && !errors.Is(err, apiErr):
event.WriteError(ctx, op, err, event.WithInfoMsg("validating keyring stored token against boundary", "auth token id", at.Id))
continue
}
ret[*t] = at.Token
}
}
if atv, ok := r.repo.idToKeyringlessAuthToken.Load(t.Id); ok {
if at, ok := atv.(*authtokens.AuthToken); ok {
_, err := r.repo.tokenReadFromBoundaryFn(ctx, u.Address, at.Token)
var apiErr *api.Error
switch {
case err != nil && api.ErrUnauthorized.Is(err):
r.repo.idToKeyringlessAuthToken.Delete(t.Id)
continue
case err != nil && !errors.Is(err, apiErr):
event.WriteError(ctx, op, err, event.WithInfoMsg("validating in memory stored token against boundary", "auth token id", at.Id))
continue
}
ret[*t] = at.Token
}
}
}
return ret, nil
}
// Refresh iterates over all tokens in the cache, attempts to read the
// resources for those tokens from boundary and updates the cache with
// the values retrieved there. Refresh accepts the options
// WithTarget which overwrites the default function used to retrieve the
// targets from a boundary address.
func (r *RefreshService) Refresh(ctx context.Context, opt ...Option) error {
const op = "cache.(RefreshService).Refresh"
if err := r.repo.cleanExpiredOrOrphanedAuthTokens(ctx); err != nil {
return errors.Wrap(ctx, err, op)
}
if err := r.repo.syncKeyringlessTokensWithDb(ctx); err != nil {
return errors.Wrap(ctx, err, op)
}
us, err := r.repo.listUsers(ctx)
if err != nil {
return errors.Wrap(ctx, err, op)
}
var retErr error
for _, u := range us {
tokens, err := r.cleanAndPickAuthTokens(ctx, u)
if err != nil {
retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op))
continue
}
if err := r.repo.refreshTargets(ctx, u, tokens, opt...); err != nil {
retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op))
}
if err := r.repo.refreshSessions(ctx, u, tokens, opt...); err != nil {
retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op))
}
}
return retErr
}
+417
View File
@@ -0,0 +1,417 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
)
// testStaticResourceRetrievalFunc returns a function that always returns the
// provided slice and a nil error. The returned function can be passed into the
// options that provide a resource retrieval func such as
// WithTargetRetrievalFunc and WithSessionRetrievalFunc.
func testStaticResourceRetrievalFunc[T any](ret []T) func(context.Context, string, string) ([]T, error) {
return func(ctx context.Context, s1, s2 string) ([]T, error) {
return ret, nil
}
}
func TestCleanAndPickTokens(t *testing.T) {
ctx := context.Background()
s, err := db.Open(ctx)
require.NoError(t, err)
boundaryAddr := "address"
u1 := &user{Id: "u1", Address: boundaryAddr}
at1a := &authtokens.AuthToken{
Id: "at_1a",
Token: "at_1a_token",
UserId: u1.Id,
ExpirationTime: time.Now().Add(time.Minute),
}
at1b := &authtokens.AuthToken{
Id: "at_1b",
Token: "at_1b_token",
UserId: u1.Id,
ExpirationTime: time.Now().Add(time.Minute),
}
keyringOnlyUser := &user{Id: "keyringUser", Address: boundaryAddr}
keyringAuthToken1 := &authtokens.AuthToken{
Id: "at_2a",
Token: "at_2a_token",
UserId: keyringOnlyUser.Id,
ExpirationTime: time.Now().Add(time.Minute),
}
keyringAuthToken2 := &authtokens.AuthToken{
Id: "at_2b",
Token: "at_2b_token",
UserId: keyringOnlyUser.Id,
ExpirationTime: time.Now().Add(time.Minute),
}
boundaryAuthTokens := []*authtokens.AuthToken{at1a, keyringAuthToken1, at1b, keyringAuthToken2}
unauthorizedAuthTokens := []*authtokens.AuthToken{}
randomErrorAuthTokens := []*authtokens.AuthToken{}
fakeBoundaryLookupFn := func(ctx context.Context, addr, at string) (*authtokens.AuthToken, error) {
for _, v := range randomErrorAuthTokens {
if at == v.Token {
return nil, errors.New("test error")
}
}
for _, v := range unauthorizedAuthTokens {
if at == v.Token {
return nil, api.ErrUnauthorized
}
}
for _, v := range boundaryAuthTokens {
if at == v.Token {
return v, nil
}
}
return nil, errors.New("not found")
}
atMap := make(map[ringToken]*authtokens.AuthToken)
r, err := NewRepository(ctx, s, &sync.Map{},
mapBasedAuthTokenKeyringLookup(atMap),
fakeBoundaryLookupFn)
require.NoError(t, err)
rs, err := NewRefreshService(ctx, r)
require.NoError(t, err)
t.Run("unknown user", func(t *testing.T) {
got, err := rs.cleanAndPickAuthTokens(ctx, &user{Id: "unknownuser", Address: "unknown"})
assert.NoError(t, err)
assert.Empty(t, got)
})
t.Run("both memory and keyring stored token", func(t *testing.T) {
key := ringToken{"k1", "t1"}
atMap[key] = at1a
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key.k,
TokenName: key.t,
AuthTokenId: at1a.Id,
}))
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token))
got, err := rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token})
// delete the keyringToken from the keyring and see it get removed from the response
delete(atMap, key)
got, err = rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1b.Token})
})
t.Run("2 memory tokens", func(t *testing.T) {
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1a.Token))
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token))
got, err := rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token})
})
t.Run("boundary in memory auth token expires", func(t *testing.T) {
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1a.Token))
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token))
got, err := rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token})
t.Cleanup(func() {
unauthorizedAuthTokens = nil
})
unauthorizedAuthTokens = []*authtokens.AuthToken{at1b}
got, err = rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token})
})
t.Run("boundary keyring auths token expires", func(t *testing.T) {
key1 := ringToken{"k1", "t1"}
atMap[key1] = keyringAuthToken1
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key1.k,
TokenName: key1.t,
AuthTokenId: keyringAuthToken1.Id,
}))
key2 := ringToken{"k2", "t2"}
atMap[key2] = keyringAuthToken2
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key2.k,
TokenName: key2.t,
AuthTokenId: keyringAuthToken2.Id,
}))
got, err := rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token, keyringAuthToken2.Token})
t.Cleanup(func() {
unauthorizedAuthTokens = nil
})
unauthorizedAuthTokens = []*authtokens.AuthToken{keyringAuthToken2}
got, err = rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token})
})
t.Run("boundary in memory auth token check errors", func(t *testing.T) {
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1a.Token))
require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token))
got, err := rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token})
t.Cleanup(func() {
randomErrorAuthTokens = nil
})
randomErrorAuthTokens = []*authtokens.AuthToken{at1b}
got, err = rs.cleanAndPickAuthTokens(ctx, u1)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token})
})
t.Run("boundary keyring auths token check errors", func(t *testing.T) {
key1 := ringToken{"k1", "t1"}
atMap[key1] = keyringAuthToken1
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key1.k,
TokenName: key1.t,
AuthTokenId: keyringAuthToken1.Id,
}))
key2 := ringToken{"k2", "t2"}
atMap[key2] = keyringAuthToken2
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key2.k,
TokenName: key2.t,
AuthTokenId: keyringAuthToken2.Id,
}))
got, err := rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token, keyringAuthToken2.Token})
t.Cleanup(func() {
randomErrorAuthTokens = nil
})
randomErrorAuthTokens = []*authtokens.AuthToken{keyringAuthToken2}
got, err = rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token})
})
t.Run("2 keyring tokens", func(t *testing.T) {
key1 := ringToken{"k1", "t1"}
atMap[key1] = keyringAuthToken1
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key1.k,
TokenName: key1.t,
AuthTokenId: keyringAuthToken1.Id,
}))
key2 := ringToken{"k2", "t2"}
atMap[key2] = keyringAuthToken2
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{
KeyringType: key2.k,
TokenName: key2.t,
AuthTokenId: keyringAuthToken2.Id,
}))
got, err := rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token, keyringAuthToken2.Token})
// Removing all keyring references and then cleaning auth tokens
// removes all auth tokens, along with the user
gotU, err := r.listUsers(ctx)
assert.NoError(t, err)
assert.Contains(t, gotU, keyringOnlyUser)
delete(atMap, key1)
delete(atMap, key2)
got, err = rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.Empty(t, got)
gotT, err := r.listTokens(ctx, keyringOnlyUser)
assert.NoError(t, err)
assert.Empty(t, gotT)
gotU, err = r.listUsers(ctx)
assert.NoError(t, err)
assert.NotContains(t, gotU, keyringOnlyUser)
})
}
func TestRefresh(t *testing.T) {
ctx := context.Background()
s, err := db.Open(ctx)
require.NoError(t, err)
boundaryAddr := "address"
u := &user{Id: "u1", Address: boundaryAddr}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
ExpirationTime: time.Now().Add(time.Minute),
}
boundaryAuthTokens := []*authtokens.AuthToken{at}
atMap := make(map[ringToken]*authtokens.AuthToken)
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
rs, err := NewRefreshService(ctx, r)
require.NoError(t, err)
atMap[ringToken{"k", "t"}] = at
require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{KeyringType: "k", TokenName: "t", AuthTokenId: at.Id}))
t.Run("set targets", func(t *testing.T) {
retTargets := []*targets.Target{
target("1"),
target("2"),
target("3"),
}
assert.NoError(t, rs.Refresh(ctx,
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)),
WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) {
require.Equal(t, boundaryAddr, addr)
require.Equal(t, at.Token, token)
return retTargets, nil
})))
cachedTargets, err := r.ListTargets(ctx, at.Id)
assert.NoError(t, err)
assert.ElementsMatch(t, retTargets, cachedTargets)
t.Run("empty response clears it out", func(t *testing.T) {
assert.NoError(t, rs.Refresh(ctx,
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)),
WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) {
require.Equal(t, boundaryAddr, addr)
require.Equal(t, at.Token, token)
return nil, nil
})))
cachedTargets, err := r.ListTargets(ctx, at.Id)
assert.NoError(t, err)
assert.Empty(t, cachedTargets)
})
})
t.Run("set sessions", func(t *testing.T) {
retSess := []*sessions.Session{
session("1"),
session("2"),
session("3"),
}
assert.NoError(t, rs.Refresh(ctx,
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)),
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(retSess))))
cachedSessions, err := r.ListSessions(ctx, at.Id)
assert.NoError(t, err)
assert.ElementsMatch(t, retSess, cachedSessions)
t.Run("empty response clears it out", func(t *testing.T) {
assert.NoError(t, rs.Refresh(ctx,
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)),
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil))))
cachedTargets, err := r.ListSessions(ctx, at.Id)
assert.NoError(t, err)
assert.Empty(t, cachedTargets)
})
})
t.Run("error propogates up", func(t *testing.T) {
innerErr := errors.New("test error")
err := rs.Refresh(ctx,
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)),
WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) {
require.Equal(t, boundaryAddr, addr)
require.Equal(t, at.Token, token)
return nil, innerErr
}))
assert.ErrorContains(t, err, innerErr.Error())
err = rs.Refresh(ctx,
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)),
WithSessionRetrievalFunc(func(ctx context.Context, addr, token string) ([]*sessions.Session, error) {
require.Equal(t, boundaryAddr, addr)
require.Equal(t, at.Token, token)
return nil, innerErr
}))
assert.ErrorContains(t, err, innerErr.Error())
})
t.Run("tokens that are no longer in the ring is deleted", func(t *testing.T) {
// Remove the token from the keyring, see that we can still see the
// token and then user until a Refresh happens which causes them to be
// cleaned up.
delete(atMap, ringToken{"k", "t"})
ps, err := r.listTokens(ctx, u)
require.NoError(t, err)
assert.Len(t, ps, 1)
us, err := r.listUsers(ctx)
require.NoError(t, err)
assert.Len(t, us, 1)
rs.Refresh(ctx,
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)),
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)))
ps, err = r.listTokens(ctx, u)
require.NoError(t, err)
assert.Empty(t, ps)
// And since the last token was deleted, the user also was deleted
us, err = r.listUsers(ctx)
require.NoError(t, err)
assert.Empty(t, us)
})
}
func target(suffix string) *targets.Target {
return &targets.Target{
Id: fmt.Sprintf("target_%s", suffix),
Name: fmt.Sprintf("name_%s", suffix),
Description: fmt.Sprintf("description_%s", suffix),
Address: fmt.Sprintf("address_%s", suffix),
Type: "tcp",
}
}
func session(suffix string) *sessions.Session {
return &sessions.Session{
Id: fmt.Sprintf("session_%s", suffix),
Type: "tcp",
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"sync"
"time"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
)
const (
usersLimit = 50
tokenStalenessLimit = 36 * time.Hour
)
// KeyringTokenLookupFn takes a token name and returns the token from the keyring
type KeyringTokenLookupFn func(keyring string, tokenName string) *authtokens.AuthToken
// BoundaryTokenReaderFn reads an auth token's resource information from boundary
type BoundaryTokenReaderFn func(ctx context.Context, addr string, authToken string) (*authtokens.AuthToken, error)
type Repository struct {
rw *db.Db
tokenKeyringFn KeyringTokenLookupFn
tokenReadFromBoundaryFn BoundaryTokenReaderFn
// idToKeyringlessAuthToken maps an auth token id to an *authtokens.AuthToken
idToKeyringlessAuthToken *sync.Map
}
// NewRepository returns a cache repository. The provided Store must be
func NewRepository(ctx context.Context, conn *db.DB, idToAuthToken *sync.Map, keyringFn KeyringTokenLookupFn, atReadFn BoundaryTokenReaderFn, opt ...Option) (*Repository, error) {
const op = "cache.NewRepository"
switch {
case util.IsNil(conn):
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing store connection")
case util.IsNil(idToAuthToken):
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing keyringless auth token map")
case util.IsNil(keyringFn):
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token keyring function")
case util.IsNil(atReadFn):
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token read function")
}
return &Repository{
rw: db.New(conn),
tokenKeyringFn: keyringFn,
tokenReadFromBoundaryFn: atReadFn,
// This is passed in instead of being fully owned by the repo so multiple
// instances of the repo can operate on the same backing data
idToKeyringlessAuthToken: idToAuthToken,
}, nil
}
func (r *Repository) SaveError(ctx context.Context, u *user, resourceType string, err error) error {
const op = "cache.(Repository).StoreError"
switch {
case resourceType == "":
return errors.New(ctx, errors.InvalidParameter, op, "resource type is empty")
case err == nil:
return errors.New(ctx, errors.InvalidParameter, op, "error is nil")
case u == nil:
return errors.New(ctx, errors.InvalidParameter, op, "user is nil")
case u.Id == "":
return errors.New(ctx, errors.InvalidParameter, op, "user id is empty")
}
apiErr := &ApiError{
UserId: u.Id,
ResourceType: resourceType,
Error: err.Error(),
}
onConflict := db.OnConflict{
Target: db.Columns{"user_id", "resource_type"},
Action: db.SetColumns([]string{"error", "create_time"}),
}
if err := r.rw.Create(ctx, apiErr, db.WithOnConflict(&onConflict)); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
type ApiError struct {
UserId string `gorm:"primaryKey"`
ResourceType string `gorm:"primaryKey"`
Error string
CreateTime time.Time
}
func (*ApiError) TableName() string {
return "api_error"
}
@@ -0,0 +1,196 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"database/sql"
"encoding/json"
stderrors "errors"
"fmt"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/types/resource"
"github.com/hashicorp/boundary/internal/util"
"github.com/hashicorp/mql"
)
// SessionRetrievalFunc is a function that retrieves sessions
// from the provided boundary addr using the provided token.
type SessionRetrievalFunc func(ctx context.Context, addr, token string) ([]*sessions.Session, error)
func defaultSessionFunc(ctx context.Context, addr, token string) ([]*sessions.Session, error) {
const op = "cache.defaultSessionFunc"
client, err := api.NewClient(&api.Config{
Addr: addr,
Token: token,
})
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
sClient := sessions.NewClient(client)
l, err := sClient.List(ctx, "global", sessions.WithRecursive(true))
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return l.Items, nil
}
func (r *Repository) refreshSessions(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error {
const op = "cache.(Repository).refreshSessions"
switch {
case util.IsNil(u):
return errors.New(ctx, errors.InvalidParameter, op, "user is nil")
case u.Id == "":
return errors.New(ctx, errors.InvalidParameter, op, "user id is missing")
case u.Address == "":
return errors.New(ctx, errors.InvalidParameter, op, "user boundary address is missing")
}
opts, err := getOpts(opt...)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if opts.withSessionRetrievalFunc == nil {
opts.withSessionRetrievalFunc = defaultSessionFunc
}
// Find and use a token for retrieving sessions
var gotResponse bool
var resp []*sessions.Session
var retErr error
for at, t := range tokens {
resp, err = opts.withSessionRetrievalFunc(ctx, u.Address, t)
if err != nil {
// TODO: If we get an error about the token no longer having
// permissions, remove it.
retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id)))
continue
}
gotResponse = true
break
}
if retErr != nil {
if saveErr := r.SaveError(ctx, u, resource.Session.String(), retErr); saveErr != nil {
return stderrors.Join(err, errors.Wrap(ctx, saveErr, op))
}
}
if !gotResponse {
return retErr
}
event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d sessions for user %v", len(resp), u))
_, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error {
// TODO: Instead of deleting everything, use refresh tokens and apply the delta
if _, err := w.Exec(ctx, "delete from session where user_id = @user_id",
[]any{sql.Named("user_id", u.Id)}); err != nil {
return err
}
for _, s := range resp {
item, err := json.Marshal(s)
if err != nil {
return err
}
newSession := &Session{
UserId: u.Id,
Id: s.Id,
Type: s.Type,
Status: s.Status,
Endpoint: s.Endpoint,
Item: string(item),
}
onConflict := db.OnConflict{
Target: db.Columns{"user_id", "id"},
Action: db.UpdateAll(true),
}
if err := w.Create(ctx, newSession, db.WithOnConflict(&onConflict)); err != nil {
return err
}
}
return nil
})
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
func (r *Repository) ListSessions(ctx context.Context, authTokenId string) ([]*sessions.Session, error) {
const op = "cache.(Repository).ListSessions"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
}
ret, err := r.searchSessions(ctx, authTokenId, "true", nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
func (r *Repository) QuerySessions(ctx context.Context, authTokenId, query string) ([]*sessions.Session, error) {
const op = "cache.(Repository).QuerySessions"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
case query == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "query is missing")
}
w, err := mql.Parse(query, Session{}, mql.WithIgnoredFields("UserId", "Item"))
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.InvalidParameter))
}
ret, err := r.searchSessions(ctx, authTokenId, w.Condition, w.Args)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
func (r *Repository) searchSessions(ctx context.Context, authTokenId, condition string, searchArgs []any) ([]*sessions.Session, error) {
const op = "cache.(Repository).searchSessions"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
case condition == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "condition is missing")
}
condition = fmt.Sprintf("%s and user_id in (select user_id from auth_token where id = ?)", condition)
args := append(searchArgs, authTokenId)
var cachedSessions []*Session
if err := r.rw.SearchWhere(ctx, &cachedSessions, condition, args, db.WithLimit(-1)); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
retSessions := make([]*sessions.Session, 0, len(cachedSessions))
for _, cachedSess := range cachedSessions {
var sess sessions.Session
if err := json.Unmarshal([]byte(cachedSess.Item), &sess); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
retSessions = append(retSessions, &sess)
}
return retSessions, nil
}
type Session struct {
UserId string `gorm:"primaryKey"`
Id string `gorm:"primaryKey"`
Type string `gorm:"default:null"`
Endpoint string `gorm:"default:null"`
Status string `gorm:"default:null"`
Item string `gorm:"default:null"`
}
func (*Session) TableName() string {
return "session"
}
@@ -0,0 +1,343 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"sync"
"testing"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/daemon/controller"
"github.com/hashicorp/boundary/internal/daemon/worker"
"github.com/hashicorp/boundary/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
_ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/targets/tcp"
)
func TestRepository_refreshSessions(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "keyring",
TokenName: "token",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{kt.KeyringType, kt.TokenName}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "status1",
Endpoint: "address1",
Type: "tcp",
},
{
Id: "ttcp_2",
Status: "status2",
Endpoint: "address2",
Type: "tcp",
},
{
Id: "ttcp_3",
Status: "status3",
Endpoint: "address3",
Type: "tcp",
},
}
cases := []struct {
name string
u *user
sess []*sessions.Session
wantCount int
errorContains string
}{
{
name: "Success",
u: &user{
Address: addr,
Id: at.UserId,
},
sess: ss,
wantCount: len(ss),
},
{
name: "repeated session with different values",
u: &user{
Address: addr,
Id: at.UserId,
},
sess: append(ss, &sessions.Session{
Id: ss[0].Id,
Status: "a different status",
}),
wantCount: len(ss),
},
{
name: "nil user",
u: nil,
sess: ss,
errorContains: "user is nil",
},
{
name: "missing user Id",
u: &user{
Address: addr,
},
sess: ss,
errorContains: "user id is missing",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := r.refreshSessions(ctx, tc.u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(tc.sess)))
if tc.errorContains == "" {
assert.NoError(t, err)
rw := db.New(s)
var got []*Session
require.NoError(t, rw.SearchWhere(ctx, &got, "true", nil))
assert.Len(t, got, tc.wantCount)
} else {
assert.ErrorContains(t, err, tc.errorContains)
}
})
}
}
func TestRepository_ListSessions(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u1 := &user{
Id: "u1",
Address: addr,
}
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u1.Id,
}
kt1 := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at1.Id,
}
u2 := &user{
Id: "u2",
Address: addr,
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: u2.Id,
}
kt2 := KeyringToken{
KeyringType: "k2",
TokenName: "t2",
AuthTokenId: at2.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at1,
{"k2", "t2"}: at2,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
require.NoError(t, r.AddKeyringToken(ctx, addr, kt2))
t.Run("auth token id is missing", func(t *testing.T) {
l, err := r.ListSessions(ctx, "")
assert.Nil(t, l)
assert.ErrorContains(t, err, "auth token id is missing")
})
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "status1",
Endpoint: "address1",
Type: "tcp",
},
{
Id: "ttcp_2",
Status: "status2",
Endpoint: "address2",
Type: "tcp",
},
{
Id: "ttcp_3",
Status: "status3",
Endpoint: "address3",
Type: "tcp",
},
}
require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(ss))))
t.Run("wrong user gets no sessions", func(t *testing.T) {
l, err := r.ListSessions(ctx, kt2.AuthTokenId)
assert.NoError(t, err)
assert.Empty(t, l)
})
t.Run("correct token gets sessions", func(t *testing.T) {
l, err := r.ListSessions(ctx, kt1.AuthTokenId)
assert.NoError(t, err)
assert.Len(t, l, len(ss))
assert.ElementsMatch(t, l, ss)
})
}
func TestRepository_QuerySessions(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u1 := &user{
Id: "u1",
Address: addr,
}
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u1.Id,
}
kt1 := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at1.Id,
}
u2 := &user{
Id: "u2",
Address: addr,
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: u2.Id,
}
kt2 := KeyringToken{
KeyringType: "k2",
TokenName: "t2",
AuthTokenId: at2.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at1,
{"k2", "t2"}: at2,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
require.NoError(t, r.AddKeyringToken(ctx, addr, kt2))
query := "status % status1 or status % status2"
errorCases := []struct {
name string
t string
query string
errContains string
}{
{
name: "auth token id is missing",
t: "",
query: query,
errContains: "auth token id is missing",
},
{
name: "query is missing",
t: "token id",
errContains: "query is missing",
},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
l, err := r.QuerySessions(ctx, tc.t, tc.query)
assert.Nil(t, l)
assert.ErrorContains(t, err, tc.errContains)
})
}
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "status1",
Endpoint: "address1",
Type: "tcp",
},
{
Id: "ttcp_2",
Status: "status2",
Endpoint: "address2",
Type: "tcp",
},
{
Id: "ttcp_3",
Status: "status3",
Endpoint: "address3",
Type: "tcp",
},
}
require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(ss))))
t.Run("wrong token gets no sessions", func(t *testing.T) {
l, err := r.QuerySessions(ctx, kt2.AuthTokenId, query)
assert.NoError(t, err)
assert.Empty(t, l)
})
t.Run("correct token gets sessions", func(t *testing.T) {
l, err := r.QuerySessions(ctx, kt1.AuthTokenId, query)
assert.NoError(t, err)
assert.Len(t, l, 2)
assert.ElementsMatch(t, l, ss[0:2])
})
}
func TestDefaultSessionRetrievalFunc(t *testing.T) {
tc := controller.NewTestController(t, nil)
tc.Client().SetToken(tc.Token().Token)
tarClient := targets.NewClient(tc.Client())
_ = worker.NewTestWorker(t, &worker.TestWorkerOpts{
InitialUpstreams: tc.ClusterAddrs(),
WorkerAuthKms: tc.Config().WorkerAuthKms,
})
tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1), targets.WithAddress("address"))
require.NoError(t, err)
require.NotNil(t, tar1)
_, err = tarClient.AuthorizeSession(tc.Context(), tar1.Item.Id)
assert.NoError(t, err)
got, err := defaultSessionFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token)
assert.NoError(t, err)
assert.Len(t, got, 1)
}
@@ -0,0 +1,193 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"database/sql"
"encoding/json"
stderrors "errors"
"fmt"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/types/resource"
"github.com/hashicorp/boundary/internal/util"
"github.com/hashicorp/mql"
)
// TargetRetrievalFunc is a function that retrieves targets
// from the provided boundary addr using the provided token.
type TargetRetrievalFunc func(ctx context.Context, addr, token string) ([]*targets.Target, error)
func defaultTargetFunc(ctx context.Context, addr, token string) ([]*targets.Target, error) {
const op = "cache.defaultTargetFunc"
client, err := api.NewClient(&api.Config{
Addr: addr,
Token: token,
})
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
tarClient := targets.NewClient(client)
l, err := tarClient.List(ctx, "global", targets.WithRecursive(true))
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return l.Items, nil
}
func (r *Repository) refreshTargets(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error {
const op = "cache.(Repository).refreshTargets"
switch {
case util.IsNil(u):
return errors.New(ctx, errors.InvalidParameter, op, "user is nil")
case u.Id == "":
return errors.New(ctx, errors.InvalidParameter, op, "user id is missing")
}
opts, err := getOpts(opt...)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if opts.withTargetRetrievalFunc == nil {
opts.withTargetRetrievalFunc = defaultTargetFunc
}
// Find and use a token for retrieving targets
var gotResponse bool
var resp []*targets.Target
var retErr error
for at, t := range tokens {
resp, err = opts.withTargetRetrievalFunc(ctx, u.Address, t)
if err != nil {
// TODO: If we get an error about the token no longer having
// permissions, remove it.
retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id)))
continue
}
gotResponse = true
break
}
if retErr != nil {
if saveErr := r.SaveError(ctx, u, resource.Target.String(), retErr); saveErr != nil {
return stderrors.Join(err, errors.Wrap(ctx, saveErr, op))
}
}
if !gotResponse {
return retErr
}
event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d targets for user %v", len(resp), u))
_, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error {
// TODO: Instead of deleting everything, use refresh tokens and apply the delta
if _, err := w.Exec(ctx, "delete from target where user_id = @user_id",
[]any{sql.Named("user_id", u.Id)}); err != nil {
return err
}
for _, t := range resp {
item, err := json.Marshal(t)
if err != nil {
return err
}
newTarget := &Target{
UserId: u.Id,
Id: t.Id,
Name: t.Name,
Description: t.Description,
Address: t.Address,
Item: string(item),
}
onConflict := db.OnConflict{
Target: db.Columns{"user_id", "id"},
Action: db.UpdateAll(true),
}
if err := w.Create(ctx, newTarget, db.WithOnConflict(&onConflict)); err != nil {
return err
}
}
return nil
})
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
func (r *Repository) ListTargets(ctx context.Context, authTokenId string) ([]*targets.Target, error) {
const op = "cache.(Repository).ListTargets"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
}
ret, err := r.searchTargets(ctx, authTokenId, "true", nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
func (r *Repository) QueryTargets(ctx context.Context, authTokenId, query string) ([]*targets.Target, error) {
const op = "cache.(Repository).QueryTargets"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
case query == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "query is missing")
}
w, err := mql.Parse(query, Target{}, mql.WithIgnoredFields("UserId", "Item"))
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.InvalidParameter))
}
ret, err := r.searchTargets(ctx, authTokenId, w.Condition, w.Args)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
func (r *Repository) searchTargets(ctx context.Context, authTokenId, condition string, searchArgs []any) ([]*targets.Target, error) {
const op = "cache.(Repository).searchTargets"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is missing")
case condition == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "condition is missing")
}
condition = fmt.Sprintf("%s and user_id in (select user_id from auth_token where id = ?)", condition)
args := append(searchArgs, authTokenId)
var cachedTargets []*Target
if err := r.rw.SearchWhere(ctx, &cachedTargets, condition, args, db.WithLimit(-1)); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
retTargets := make([]*targets.Target, 0, len(cachedTargets))
for _, cachedTar := range cachedTargets {
var tar targets.Target
if err := json.Unmarshal([]byte(cachedTar.Item), &tar); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
retTargets = append(retTargets, &tar)
}
return retTargets, nil
}
type Target struct {
UserId string `gorm:"primaryKey"`
Id string `gorm:"primaryKey"`
Name string `gorm:"default:null"`
Description string `gorm:"default:null"`
Address string `gorm:"default:null"`
Item string `gorm:"default:null"`
}
func (*Target) TableName() string {
return "target"
}
@@ -0,0 +1,328 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"sync"
"testing"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/api/targets"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/daemon/controller"
"github.com/hashicorp/boundary/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
)
func TestRepository_refreshTargets(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{KeyringType: "k", TokenName: "t", AuthTokenId: at.Id}
atMap := map[ringToken]*authtokens.AuthToken{
{"k", "t"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
ts := []*targets.Target{
{
Id: "ttcp_1",
Name: "name1",
Type: "tcp",
SessionMaxSeconds: 111,
},
{
Id: "ttcp_2",
Name: "name2",
Address: "address2",
Type: "tcp",
SessionMaxSeconds: 222,
},
{
Id: "ttcp_3",
Name: "name3",
Address: "address3",
Type: "tcp",
SessionMaxSeconds: 333,
},
}
cases := []struct {
name string
u *user
targets []*targets.Target
wantCount int
errorContains string
}{
{
name: "Success",
u: &user{
Id: at.UserId,
Address: addr,
},
targets: ts,
wantCount: len(ts),
},
{
name: "repeated target with different values",
u: &user{
Address: addr,
Id: at.UserId,
},
targets: append(ts, &targets.Target{
Id: ts[0].Id,
Name: "a different name",
}),
wantCount: len(ts),
},
{
name: "nil user",
u: nil,
targets: ts,
errorContains: "user is nil",
},
{
name: "missing user Id",
u: &user{
Address: addr,
},
targets: ts,
errorContains: "user id is missing",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := r.refreshTargets(ctx, tc.u, map[AuthToken]string{{Id: "id"}: "something"},
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(tc.targets)))
if tc.errorContains == "" {
assert.NoError(t, err)
rw := db.New(s)
var got []*Target
require.NoError(t, rw.SearchWhere(ctx, &got, "true", nil))
assert.Len(t, got, tc.wantCount)
} else {
assert.ErrorContains(t, err, tc.errorContains)
}
})
}
}
func TestRepository_ListTargets(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u1 := &user{
Id: "u1",
Address: addr,
}
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u1.Id,
}
kt1 := KeyringToken{KeyringType: "k1", TokenName: "t1", AuthTokenId: at1.Id}
u2 := &user{
Id: "u2",
Address: addr,
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: u2.Id,
}
kt2 := KeyringToken{KeyringType: "k2", TokenName: "t2", AuthTokenId: at2.Id}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at1,
{"k2", "t2"}: at2,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
require.NoError(t, r.AddKeyringToken(ctx, addr, kt2))
t.Run("token is missing", func(t *testing.T) {
l, err := r.ListTargets(ctx, "")
assert.Nil(t, l)
assert.ErrorContains(t, err, "auth token id is missing")
})
ts := []*targets.Target{
{
Id: "ttcp_1",
Name: "name1",
Address: "address1",
Type: "tcp",
SessionMaxSeconds: 111,
},
{
Id: "ttcp_2",
Name: "name2",
Address: "address2",
Type: "tcp",
SessionMaxSeconds: 222,
},
{
Id: "ttcp_3",
Name: "name3",
Address: "address3",
Type: "tcp",
SessionMaxSeconds: 333,
},
}
require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"},
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(ts))))
t.Run("wrong user gets no targets", func(t *testing.T) {
l, err := r.ListTargets(ctx, kt2.AuthTokenId)
assert.NoError(t, err)
assert.Empty(t, l)
})
t.Run("correct token gets targets", func(t *testing.T) {
l, err := r.ListTargets(ctx, kt1.AuthTokenId)
assert.NoError(t, err)
assert.Len(t, l, len(ts))
assert.ElementsMatch(t, l, ts)
})
}
func TestRepository_QueryTargets(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u1 := &user{
Id: "u1",
Address: addr,
}
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u1.Id,
}
kt1 := KeyringToken{KeyringType: "k1", TokenName: "t1", AuthTokenId: at1.Id}
u2 := &user{
Id: "u2",
Address: addr,
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: u2.Id,
}
kt2 := KeyringToken{KeyringType: "k2", TokenName: "t2", AuthTokenId: at2.Id}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at1,
{"k2", "t2"}: at2,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
require.NoError(t, r.AddKeyringToken(ctx, addr, kt2))
query := "name % name1 or name % name2"
errorCases := []struct {
name string
p string
query string
errContains string
}{
{
name: "auth token id is missing",
p: "",
query: query,
errContains: "auth token id is missing",
},
{
name: "query is missing",
p: "authtokenid",
errContains: "query is missing",
},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
l, err := r.QueryTargets(ctx, tc.p, tc.query)
assert.Nil(t, l)
assert.ErrorContains(t, err, tc.errContains)
})
}
ts := []*targets.Target{
{
Id: "ttcp_1",
Name: "name1",
Address: "address1",
Type: "tcp",
SessionMaxSeconds: 111,
},
{
Id: "ttcp_2",
Name: "name2",
Address: "address2",
Type: "tcp",
SessionMaxSeconds: 222,
},
{
Id: "ttcp_3",
Name: "name3",
Address: "address3",
Type: "tcp",
SessionMaxSeconds: 333,
},
}
require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"},
WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(ts))))
t.Run("wrong token gets no targets", func(t *testing.T) {
l, err := r.QueryTargets(ctx, kt2.AuthTokenId, query)
assert.NoError(t, err)
assert.Empty(t, l)
})
t.Run("correct token gets targets", func(t *testing.T) {
l, err := r.QueryTargets(ctx, kt1.AuthTokenId, query)
assert.NoError(t, err)
assert.Len(t, l, 2)
assert.ElementsMatch(t, l, ts[0:2])
})
}
func TestDefaultTargetRetrievalFunc(t *testing.T) {
tc := controller.NewTestController(t, nil)
tc.Client().SetToken(tc.Token().Token)
tarClient := targets.NewClient(tc.Client())
tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1))
require.NoError(t, err)
require.NotNil(t, tar1)
tar2, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar2"), targets.WithTcpTargetDefaultPort(2))
require.NoError(t, err)
require.NotNil(t, tar2)
got, err := defaultTargetFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token)
assert.NoError(t, err)
assert.Contains(t, got, tar1.Item)
assert.Contains(t, got, tar2.Item)
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
stdErrors "errors"
"fmt"
"sync"
"testing"
"github.com/hashicorp/boundary/api/authtokens"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ringToken is a test struct used to group a keyring type and token name
// so it can be used in an authtoken lookup function.
type ringToken struct {
k string
t string
}
// mapBasedAuthTokenKeyringLookup provides a fake KeyringTokenLookupFn that uses
// the provided map to perform lookups for the tokens
func mapBasedAuthTokenKeyringLookup(m map[ringToken]*authtokens.AuthToken) KeyringTokenLookupFn {
return func(k, t string) *authtokens.AuthToken {
return m[ringToken{k, t}]
}
}
// sliceBasedAuthTokenBoundaryReader provides a fake BoundaryTokenReaderFn that uses
// the provided map to lookup an auth tokens information.
func sliceBasedAuthTokenBoundaryReader(s []*authtokens.AuthToken) BoundaryTokenReaderFn {
return func(ctx context.Context, addr, at string) (*authtokens.AuthToken, error) {
for _, v := range s {
if at == v.Token {
return v, nil
}
}
return nil, stdErrors.New("not found")
}
}
func TestRepository_SaveError(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
r, err := NewRepository(ctx, s, &sync.Map{},
mapBasedAuthTokenKeyringLookup(map[ringToken]*authtokens.AuthToken{}),
sliceBasedAuthTokenBoundaryReader(nil))
require.NoError(t, err)
testResource := "test_resource_type"
testErr := fmt.Errorf("test error for %q", testResource)
u := &user{
Id: "u1",
Address: "addr",
}
require.NoError(t, r.rw.Create(ctx, u))
t.Run("empty resource type", func(t *testing.T) {
assert.ErrorContains(t, r.SaveError(ctx, u, "", testErr), "resource type is empty")
})
t.Run("nil error", func(t *testing.T) {
assert.ErrorContains(t, r.SaveError(ctx, u, testResource, nil), "error is nil")
})
t.Run("nil user", func(t *testing.T) {
assert.ErrorContains(t, r.SaveError(ctx, nil, testResource, testErr), "user is nil")
})
t.Run("success", func(t *testing.T) {
assert.NoError(t, r.SaveError(ctx, u, testResource, testErr))
})
}
+527
View File
@@ -0,0 +1,527 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"database/sql"
"fmt"
"strings"
"sync"
"time"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
)
// AuthTokenIdSegmentCount are the number of segments, delineated by "_", that
// make up the auth token id inside an auth token.
// For example, an authtoken format should look something like at_1234567890_sometokenpayload
const AuthTokenIdSegmentCount = 2
// upsertUserAndAuthToken upserts a user and authToken using the data in the provided authtoken.
// If creating this user results in the number of users exceeding the limit of
// allowed users it deletes the oldest one.
func upsertUserAndAuthToken(ctx context.Context, reader db.Reader, writer db.Writer, bAddr string, at *authtokens.AuthToken) error {
const op = "cache.upsertUserAndAuthToken"
switch {
case util.IsNil(reader):
return errors.New(ctx, errors.InvalidParameter, op, "reader is nil")
case util.IsNil(writer):
return errors.New(ctx, errors.InvalidParameter, op, "writer is nil")
case !writer.IsTx(ctx):
return errors.New(ctx, errors.InvalidParameter, op, "writer isn't part of an inflight transaction")
case util.IsNil(at):
return errors.New(ctx, errors.InvalidParameter, op, "auth token is nil")
case bAddr == "":
return errors.New(ctx, errors.InvalidParameter, op, "boundary address is empty")
case at.Id == "":
return errors.New(ctx, errors.InvalidParameter, op, "auth token id is empty")
case at.UserId == "":
return errors.New(ctx, errors.InvalidParameter, op, "auth token user id is empty")
}
{
// always make sure the user exists when adding a token
u := &user{
Id: at.UserId,
Address: bAddr,
}
onConflict := &db.OnConflict{
Target: db.Columns{"id"},
Action: db.DoNothing(true),
}
if err := writer.Create(ctx, u, db.WithOnConflict(onConflict)); err != nil {
return errors.Wrap(ctx, err, op)
}
}
{
st := &AuthToken{
Id: at.Id,
UserId: at.UserId,
LastAccessedTime: time.Now(),
ExpirationTime: at.ExpirationTime,
}
onConflict := &db.OnConflict{
Target: db.Columns{"id"},
Action: db.SetColumns([]string{"last_accessed_time", "expiration_time"}),
}
if err := writer.Create(ctx, st, db.WithOnConflict(onConflict)); err != nil {
return errors.Wrap(ctx, err, op)
}
}
var users []*user
if err := reader.SearchWhere(ctx, &users, "true", []any{}, db.WithLimit(-1)); err != nil {
return errors.Wrap(ctx, err, op)
}
if len(users) <= usersLimit {
return nil
}
var oldestUser *user
var oldestUsersTime *time.Time
for _, u := range users {
ats, err := listTokens(ctx, reader, u)
if err != nil {
return errors.Wrap(ctx, err, op)
}
for _, at := range ats {
if oldestUsersTime == nil || oldestUsersTime.After(at.LastAccessedTime) {
oldestUser = u
oldestUsersTime = &at.LastAccessedTime
}
}
}
if oldestUser != nil {
if _, err := deleteUser(ctx, writer, oldestUser); err != nil {
return errors.Wrap(ctx, err, op)
}
}
return nil
}
// AddRawToken upserts the auth token's user and auth token in the db and
// stores the actual raw auth token in the repositories in memory storage.
// The raw token must be valid and present in boundary and be for a user that
// has permission to send a Read request for itself to boundary.
func (r *Repository) AddRawToken(ctx context.Context, bAddr string, rawToken string) error {
const op = "cache.(Repository).AddRawToken"
switch {
case rawToken == "":
return errors.New(ctx, errors.InvalidParameter, op, "boundary auth token is empty")
case bAddr == "":
return errors.New(ctx, errors.InvalidParameter, op, "boundary address is empty")
}
// rawToken should look something like at_1234567890_someencryptedpayload
atIdParts := strings.SplitN(rawToken, "_", 4)
if len(atIdParts) != 3 {
return errors.New(ctx, errors.InvalidParameter, op, "boundary auth token is is malformed")
}
atId := strings.Join(atIdParts[:AuthTokenIdSegmentCount], "_")
var at *authtokens.AuthToken
{
var inMemAuthToken *authtokens.AuthToken
atV, inMem := r.idToKeyringlessAuthToken.Load(atId)
if inMem {
var ok bool
inMemAuthToken, ok = atV.(*authtokens.AuthToken)
if !ok {
return errors.New(ctx, errors.Internal, op, "unable to cast in memory auth token to *authtoken.AuthToken")
}
}
t := &AuthToken{
Id: atId,
}
err := r.rw.LookupById(ctx, t)
switch {
case err != nil && !errors.IsNotFoundError(err):
return errors.Wrap(ctx, err, op)
case errors.IsNotFoundError(err) || !inMem:
// if we don't know about it in the cache or we don't know about
// this auth token in memory, get it from boundary to sure up the
// cache information about this auth token.
at, err = r.tokenReadFromBoundaryFn(ctx, bAddr, rawToken)
if err != nil {
return errors.Wrap(ctx, err, op)
}
case t.UserId != inMemAuthToken.UserId:
return errors.New(ctx, errors.InvalidParameter, op, "user id doesn't match what is specified in the stored auth token")
case inMem:
at = inMemAuthToken
}
}
if at != nil {
// The token is never returned from boundary except in the original auth
// request so we must set the token.
at.Token = rawToken
}
_, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error {
if err := upsertUserAndAuthToken(ctx, reader, writer, bAddr, at); err != nil {
return errors.Wrap(ctx, err, op)
}
r.idToKeyringlessAuthToken.Store(at.Id, at)
return nil
})
if err != nil {
return err
}
return nil
}
// AddKeyringToken adds a token to the repository. If the token's id in the
// keyring doesn't match the id provided an error is returned.
// The token must be valid and present in boundary and be for a user that
// has permission to send a self-Read request to boundary. The user id
// stored in the keyring must also match the user id returned from boundary.
func (r *Repository) AddKeyringToken(ctx context.Context, bAddr string, token KeyringToken) error {
const op = "cache.(Repository).AddKeyringToken"
switch {
case token.TokenName == "":
return errors.New(ctx, errors.InvalidParameter, op, "token name is empty")
case token.KeyringType == "":
return errors.New(ctx, errors.InvalidParameter, op, "keyring type is empty")
case token.AuthTokenId == "":
return errors.New(ctx, errors.InvalidParameter, op, "boundary auth token id is empty")
case bAddr == "":
return errors.New(ctx, errors.InvalidParameter, op, "boundary address is empty")
}
kt := token.clone()
keyringStoredAt := r.tokenKeyringFn(kt.KeyringType, kt.TokenName)
if keyringStoredAt == nil {
return errors.New(ctx, errors.InvalidParameter, op, "unable to find token in the keyring specified")
}
if kt.AuthTokenId != keyringStoredAt.Id {
return errors.New(ctx, errors.InvalidParameter, op, "provided auth token id doesn't match the one stored")
}
var at *authtokens.AuthToken
{
cachedAt := &AuthToken{
Id: kt.AuthTokenId,
}
err := r.rw.LookupById(ctx, cachedAt)
switch {
case err != nil && !errors.IsNotFoundError(err):
return errors.Wrap(ctx, err, op)
case errors.IsNotFoundError(err):
at, err = r.tokenReadFromBoundaryFn(ctx, bAddr, keyringStoredAt.Token)
if err != nil {
return errors.Wrap(ctx, err, op)
}
case cachedAt.UserId != keyringStoredAt.UserId:
return errors.New(ctx, errors.InvalidParameter, op, "user id doesn't match what is specified in the stored auth token")
default:
at = keyringStoredAt
}
}
_, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error {
if err := upsertUserAndAuthToken(ctx, reader, writer, bAddr, at); err != nil {
return errors.Wrap(ctx, err, op)
}
onConflict := &db.OnConflict{
Target: db.Columns{"keyring_type", "token_name"},
Action: db.SetColumns([]string{"auth_token_id"}),
}
if err := writer.Create(ctx, kt, db.WithOnConflict(onConflict)); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
})
if err != nil {
return err
}
return nil
}
// LookupToken returns the Token in the cache if one exists.
// Accepts withUpdateLastAccessedTime options. If withUpdateLastAccessedTime
// is provided, the last update time of the returned token will be updated to
// the current time and reflected in the db. The returned AuthToken will not
// have the updated time.
func (r *Repository) LookupToken(ctx context.Context, authTokenId string, opt ...Option) (*AuthToken, error) {
const op = "cache.(Repository).LookupToken"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is empty")
}
at := &AuthToken{
Id: authTokenId,
}
if err := r.rw.LookupById(ctx, at); err != nil {
if errors.IsNotFoundError(err) {
return nil, nil
}
return nil, errors.Wrap(ctx, err, op)
}
opts, err := getOpts(opt...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if opts.withUpdateLastAccessedTime {
updatedT := &AuthToken{
Id: authTokenId,
LastAccessedTime: time.Now(),
}
if _, err := r.rw.Update(ctx, updatedT, []string{"LastAccessedTime"}, nil); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
}
return at, nil
}
// deleteKeyringToken deletes a keyring token
func (r *Repository) deleteKeyringToken(ctx context.Context, kt KeyringToken) error {
const op = "cache.(Repository).deleteKeyringToken"
switch {
case kt.KeyringType == "":
return errors.New(ctx, errors.InvalidParameter, op, "missing keyring type")
case kt.TokenName == "":
return errors.New(ctx, errors.InvalidParameter, op, "token name type")
}
_, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error {
// TODO(https://github.com/go-gorm/gorm/issues/4879): Use the
// writer.Delete() function once the gorm bug is fixed. Until then
// the gorm driver for sqlite has an error which wont execute a
// delete correctly. as a work around we manually execute the
// query here.
n, err := writer.Exec(ctx, "delete from keyring_token where (keyring_type, token_name) = (?, ?)", []any{kt.KeyringType, kt.TokenName})
if err != nil {
return errors.Wrap(ctx, err, op)
}
switch n {
case 1:
if err := cleanExpiredOrOrphanedAuthTokens(ctx, writer, r.idToKeyringlessAuthToken); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
case 0:
return errors.New(ctx, errors.RecordNotFound, op, "token not found when attempting deletion")
default:
return errors.New(ctx, errors.MultipleRecords, op, "multiple tokens deleted when one was requested")
}
})
if err != nil {
return err
}
return nil
}
// cleanExpiredOrOrphanedAuthTokens removes all tokens which are older than the staleness limit
// or does not have either a keyring or keyringless reference to it.
func (r *Repository) cleanExpiredOrOrphanedAuthTokens(ctx context.Context) error {
const op = "cache.Repository.cleanExpiredOrOrphanedAuthTokens"
_, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error {
if err := cleanExpiredOrOrphanedAuthTokens(ctx, writer, r.idToKeyringlessAuthToken); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
})
return err
}
// cleanExpiredOrOrphanedAuthTokens removes all tokens which are older than the staleness limit
// or does not have either a keyring or keyringless reference to it.
func cleanExpiredOrOrphanedAuthTokens(ctx context.Context, writer db.Writer, idToKeyringlessAuthToken *sync.Map) error {
const op = "cache.cleanExpiredOrOrphanedAuthTokens"
switch {
case util.IsNil(writer):
return errors.New(ctx, errors.InvalidParameter, op, "writer is nil")
case idToKeyringlessAuthToken == nil:
return errors.New(ctx, errors.InvalidParameter, op, "keyringless auth token map is nil")
case !writer.IsTx(ctx):
return errors.New(ctx, errors.InvalidParameter, op, "writer isn't part of an inflight transaction")
}
var keyringlessAuthTokens []string
idToKeyringlessAuthToken.Range(func(key, _ any) bool {
keyringlessAuthTokens = append(keyringlessAuthTokens, key.(string))
return true
})
deleteOrphanedAuthTokens := `
delete from auth_token
where
last_accessed_time < @last_accessed_time
or
-- sqlite stores expiration_time as a string in a format that might not match
-- what is being used by current_timestamp. Using datetime() makes the
-- formats match which allow the string comparison performed here to work
-- the same as a time comparison.
datetime(expiration_time) < current_timestamp
or
%s
`
args := []any{sql.Named("last_accessed_time", time.Now().Add(-tokenStalenessLimit))}
idInSection := "id not in (select auth_token_id from keyring_token)"
if len(keyringlessAuthTokens) > 0 {
// Note: We have to build the statement like this because if the slice of string
// is empty this gets converted to a query that says " and id not in (NULL)"
idInSection = fmt.Sprintf("(%s and id not in @keyringless_token_ids)", idInSection)
args = append(args, sql.Named("keyringless_token_ids", keyringlessAuthTokens))
}
if _, err := writer.Exec(ctx, fmt.Sprintf(deleteOrphanedAuthTokens, idInSection), args); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// listUsers returns all known tokens in the cache
func (r *Repository) listUsers(ctx context.Context) ([]*user, error) {
const op = "cache.(Repository).listUsers"
var ret []*user
if err := r.rw.SearchWhere(ctx, &ret, "true", nil); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
// listTokens returns all known tokens in the cache for the provided user
func (r *Repository) listTokens(ctx context.Context, u *user) ([]*AuthToken, error) {
const op = "cache.(Repository).listTokens"
ret, err := listTokens(ctx, r.rw, u)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
// listTokens returns all known tokens in the cache for the provided user using the provided reader
func listTokens(ctx context.Context, reader db.Reader, u *user) ([]*AuthToken, error) {
const op = "cache.listTokens"
switch {
case util.IsNil(u):
return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil")
case u.Id == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is missing")
}
var ret []*AuthToken
if err := reader.SearchWhere(ctx, &ret, "user_id = ?", []any{u.Id}); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
// listKeyringTokens returns all known keyring tokens in the cache for the provided auth token
func (r *Repository) listKeyringTokens(ctx context.Context, at *AuthToken) ([]*KeyringToken, error) {
const op = "cache.(Repository).listTokens"
switch {
case util.IsNil(at):
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token is nil")
case at.Id == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is empty")
}
var ret []*KeyringToken
if err := r.rw.SearchWhere(ctx, &ret, "auth_token_id = ?", []any{at.Id}); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
// syncKeyringlessTokensWithDb removes the in memory storage of auth tokens if
// they are no longer represented in the db.
func (r *Repository) syncKeyringlessTokensWithDb(ctx context.Context) error {
const op = "cache.(Repository).syncKeyringlessTokensWithDb"
var ret []*AuthToken
if err := r.rw.SearchWhere(ctx, &ret, "true", nil); err != nil {
return errors.Wrap(ctx, err, op)
}
authTokenIds := make(map[string]struct{})
for _, at := range ret {
authTokenIds[at.Id] = struct{}{}
}
r.idToKeyringlessAuthToken.Range(func(key, value any) bool {
k := key.(string)
if _, ok := authTokenIds[k]; !ok {
r.idToKeyringlessAuthToken.Delete(key)
}
return true
})
return nil
}
// deleteUser executes a delete command using the provided db.Writer for the provided user.
func deleteUser(ctx context.Context, w db.Writer, u *user) (int, error) {
const op = "cache.deleteUser"
switch {
case util.IsNil(w):
return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "writer is nil")
case u.Id == "":
return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "missing id")
}
// TODO(https://github.com/go-gorm/gorm/issues/4879): Use the
// writer.Delete() function once the gorm bug is fixed. Until then
// the gorm driver for sqlite has an error which wont execute a
// delete correctly. as a work around we manually execute the
// query here.
n, err := w.Exec(ctx, "delete from user where id = ?", []any{u.Id})
if err != nil {
err = errors.Wrap(ctx, err, op)
}
return n, err
}
// user is a gorm model for the user table. It represents a user
type user struct {
Id string `gorm:"primaryKey"`
Address string `gorm:"default:null"`
}
func (*user) TableName() string {
return "user"
}
func (u *user) clone() *user {
return &user{
Id: u.Id,
Address: u.Address,
}
}
// AuthToken is a gorm model for the token table and is an auth token known by the
// boundary client side cache daemon.
type AuthToken struct {
Id string `gorm:"primaryKey"`
UserId string `gorm:"default:null"`
LastAccessedTime time.Time `gorm:"default:(strftime('%Y-%m-%d %H:%M:%f','now'))"`
ExpirationTime time.Time
}
func (*AuthToken) TableName() string {
return "auth_token"
}
// KeyringToken is a gorm model for the keyring stored token
type KeyringToken struct {
KeyringType string `gorm:"primaryKey"`
TokenName string `gorm:"primaryKey"`
AuthTokenId string `gorm:"default:null"`
}
func (*KeyringToken) TableName() string {
return "keyring_token"
}
func (kt *KeyringToken) clone() *KeyringToken {
return &KeyringToken{
KeyringType: kt.KeyringType,
TokenName: kt.TokenName,
AuthTokenId: kt.AuthTokenId,
}
}
@@ -0,0 +1,822 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/api/authtokens"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
)
func TestRepository_AddKeyringToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
// used to test mismatched user ids between keyring and cache
mismatchingAt := &authtokens.AuthToken{
Id: "at_mismatch",
Token: "at_mismatch_token",
UserId: u.Id,
}
boundaryAuthTokens := []*authtokens.AuthToken{at, mismatchingAt}
keyring := "k"
tokenName := "t"
atMap := make(map[ringToken]*authtokens.AuthToken)
atMap[ringToken{keyring, tokenName}] = at
atMap[ringToken{"mismatch", "mismatch"}] = mismatchingAt
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
t.Run("userid mismatch between db and keyring", func(t *testing.T) {
require.NoError(t, r.AddKeyringToken(ctx, "address", KeyringToken{
KeyringType: "mismatch",
TokenName: "mismatch",
AuthTokenId: mismatchingAt.Id,
}))
mismatchingAt.UserId = "changedToMismatch"
assert.ErrorContains(t, r.AddKeyringToken(ctx, "address", KeyringToken{
KeyringType: "mismatch",
TokenName: "mismatch",
AuthTokenId: mismatchingAt.Id,
}), "user id doesn't match what is specified in the stored auth token")
})
errCases := []struct {
name string
addr string
kt KeyringToken
errorContains string
}{
{
name: "success",
addr: "address",
kt: KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
AuthTokenId: at.Id,
},
errorContains: "",
},
{
name: "not in keyring",
addr: "address",
kt: KeyringToken{
KeyringType: keyring,
TokenName: "unknowntokenname",
AuthTokenId: at.Id,
},
errorContains: "unable to find token in the keyring specified",
},
{
name: "missing address",
kt: KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
AuthTokenId: at.Id,
},
errorContains: "boundary address is empty",
},
{
name: "missing token",
addr: "address",
kt: KeyringToken{
KeyringType: keyring,
AuthTokenId: at.Id,
},
errorContains: "token name is empty",
},
{
name: "missing keyring type",
addr: "address",
kt: KeyringToken{
TokenName: tokenName,
AuthTokenId: at.Id,
},
errorContains: "keyring type is empty",
},
{
name: "missing auth token id",
addr: "address",
kt: KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
},
errorContains: "boundary auth token id is empty",
},
{
name: "unmatching auth token id",
addr: "address",
kt: KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
AuthTokenId: "wrong auth token id",
},
errorContains: "provided auth token id doesn't match",
},
}
for _, tc := range errCases {
t.Run(tc.name, func(t *testing.T) {
err := r.AddKeyringToken(ctx, tc.addr, tc.kt)
if tc.errorContains == "" {
require.NoError(t, err)
} else {
assert.ErrorContains(t, err, tc.errorContains)
}
})
}
}
func TestRepository_AddRawToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "u1",
}
existingAt := &authtokens.AuthToken{
Id: "at_existing",
Token: "at_existing_token",
UserId: "u2",
}
boundaryAuthTokens := []*authtokens.AuthToken{at, existingAt}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(map[ringToken]*authtokens.AuthToken{}), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
t.Run("mismatched userid between memory and db", func(t *testing.T) {
require.NoError(t, r.AddRawToken(ctx, "address", existingAt.Token))
loadedExistingV, loaded := r.idToKeyringlessAuthToken.Load(existingAt.Id)
require.True(t, loaded)
loadedExistingAt := loadedExistingV.(*authtokens.AuthToken)
loadedExistingAt.UserId = "mismatchingUserId"
r.idToKeyringlessAuthToken.Store(existingAt.Id, loadedExistingAt)
err := r.AddRawToken(ctx, "address", loadedExistingAt.Token)
assert.Error(t, err)
assert.ErrorContains(t, err, "user id doesn't match what is specified in the stored auth token")
})
errCases := []struct {
name string
addr string
rawAt string
errorContains string
}{
{
name: "success",
addr: "address",
rawAt: at.Token,
errorContains: "",
},
{
name: "missing address",
rawAt: at.Token,
errorContains: "boundary address is empty",
},
{
name: "missing token",
addr: "address",
errorContains: "auth token is empty",
},
{
name: "malformed auth token",
addr: "address",
rawAt: fmt.Sprintf("%s_extraunderscore", at.Token),
errorContains: "boundary auth token is is malformed",
},
{
name: "not found in boundary",
addr: "address",
rawAt: "at_123_notfoundinboundary",
errorContains: "not found",
},
}
for _, tc := range errCases {
t.Run(tc.name, func(t *testing.T) {
err := r.AddRawToken(ctx, tc.addr, tc.rawAt)
if tc.errorContains == "" {
require.NoError(t, err)
} else {
assert.ErrorContains(t, err, tc.errorContains)
}
})
}
}
func TestRepository_AddToken_EvictsOverLimitUsers(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
boundaryAuthTokens := []*authtokens.AuthToken{
{
UserId: "user_base",
Id: "at_base",
Token: "at_base_token",
},
}
for i := 0; i < usersLimit; i++ {
iAt := &authtokens.AuthToken{
UserId: fmt.Sprintf("user%d", i),
Id: fmt.Sprintf("at_%d", i),
}
iAt.Token = fmt.Sprintf("%s_token", iAt.Id)
boundaryAuthTokens = append(boundaryAuthTokens, iAt)
}
atMap := make(map[ringToken]*authtokens.AuthToken)
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
addr := "address"
kt := KeyringToken{
KeyringType: "keyring",
TokenName: "token",
AuthTokenId: boundaryAuthTokens[0].Id,
}
atMap[ringToken{kt.KeyringType, kt.TokenName}] = boundaryAuthTokens[0]
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt))
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt))
lastKtAdded := kt
for i, at := range boundaryAuthTokens[1:] {
kr := fmt.Sprintf("%s%d", kt.KeyringType, i)
tn := fmt.Sprintf("%s%d", kt.TokenName, i)
ikt := KeyringToken{KeyringType: kr, TokenName: tn, AuthTokenId: at.Id}
atMap[ringToken{ikt.KeyringType, ikt.TokenName}] = at
assert.NoError(t, r.AddKeyringToken(ctx, addr, ikt))
lastKtAdded = ikt
}
// Lookup the first token added. It should have been deleted when the
// associated user was evicted for being used the least recently.
gotP, err := r.LookupToken(ctx, kt.AuthTokenId)
assert.NoError(t, err)
assert.Nil(t, gotP)
gotP, err = r.LookupToken(ctx, lastKtAdded.AuthTokenId)
assert.NoError(t, err)
assert.NotEmpty(t, gotP)
}
func TestRepository_AddToken_EvictsOverLimit_Keyringless(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
boundaryAuthTokens := []*authtokens.AuthToken{
{
UserId: "user_base",
Id: "at_base",
Token: "at_base_token",
},
}
for i := 0; i < usersLimit; i++ {
iAt := &authtokens.AuthToken{
UserId: fmt.Sprintf("user%d", i),
Id: fmt.Sprintf("at_%d", i),
}
iAt.Token = fmt.Sprintf("%s_token", iAt.Id)
boundaryAuthTokens = append(boundaryAuthTokens, iAt)
}
atMap := make(map[ringToken]*authtokens.AuthToken)
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
addr := "address"
assert.NoError(t, r.AddRawToken(ctx, addr, boundaryAuthTokens[0].Token))
assert.NoError(t, r.AddRawToken(ctx, addr, boundaryAuthTokens[0].Token))
for _, at := range boundaryAuthTokens[1:] {
assert.NoError(t, r.AddRawToken(ctx, addr, at.Token))
}
// Lookup the first persona added. It should have been evicted from the db
// for being used the least recently. It is only removed from the db once
// cleanAuthTokens is called.
gotP, err := r.LookupToken(ctx, boundaryAuthTokens[0].Id)
assert.NoError(t, err)
assert.Nil(t, gotP)
_, ok := r.idToKeyringlessAuthToken.Load(boundaryAuthTokens[0].Id)
assert.True(t, ok)
gotP, err = r.LookupToken(ctx, boundaryAuthTokens[len(boundaryAuthTokens)-1].Id)
assert.NoError(t, err)
assert.NotEmpty(t, gotP)
_, ok = r.idToKeyringlessAuthToken.Load(boundaryAuthTokens[len(boundaryAuthTokens)-1].Id)
assert.True(t, ok)
assert.NoError(t, r.syncKeyringlessTokensWithDb(ctx))
_, ok = r.idToKeyringlessAuthToken.Load(boundaryAuthTokens[0].Id)
assert.False(t, ok)
}
func TestRepository_CleanAuthTokens(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
at := &authtokens.AuthToken{
UserId: "user_base",
Id: "at_base",
Token: "at_base_token",
}
boundaryAuthTokens := []*authtokens.AuthToken{at}
atMap := make(map[ringToken]*authtokens.AuthToken)
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
assert.NoError(t, r.AddRawToken(ctx, "baddr", at.Token))
_, present := r.idToKeyringlessAuthToken.Load(at.Id)
assert.True(t, present)
_, err = r.rw.Delete(ctx, &user{Id: at.UserId})
require.NoError(t, err)
_, present = r.idToKeyringlessAuthToken.Load(at.Id)
assert.True(t, present)
assert.NoError(t, r.syncKeyringlessTokensWithDb(ctx))
_, present = r.idToKeyringlessAuthToken.Load(at.Id)
assert.False(t, present)
}
func TestRepository_AddKeyringToken_AddingExistingUpdatesLastAccessedTime(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "u_1",
}
kt1 := KeyringToken{
TokenName: "t1",
KeyringType: "k1",
AuthTokenId: at1.Id,
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: "u_2",
}
kt2 := KeyringToken{
TokenName: "t2",
KeyringType: "k2",
AuthTokenId: "at_2",
}
boundaryAuthTokens := []*authtokens.AuthToken{at1, at2}
atMap := map[ringToken]*authtokens.AuthToken{
{kt1.KeyringType, kt1.TokenName}: at1,
{kt2.KeyringType, kt2.TokenName}: at2,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt2))
time.Sleep(10 * time.Millisecond)
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
gotP1, err := r.LookupToken(ctx, kt1.AuthTokenId)
require.NoError(t, err)
require.NotNil(t, gotP1)
gotP2, err := r.LookupToken(ctx, kt2.AuthTokenId)
require.NoError(t, err)
require.NotNil(t, gotP2)
assert.Greater(t, gotP1.LastAccessedTime, gotP2.LastAccessedTime)
}
func TestRepository_AddRawToken_AddingExistingUpdatesLastAccessedTime(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "u_1",
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: "u_2",
}
boundaryAuthTokens := []*authtokens.AuthToken{at1, at2}
atMap := map[ringToken]*authtokens.AuthToken{}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
assert.NoError(t, r.AddRawToken(ctx, addr, at1.Token))
assert.NoError(t, r.AddRawToken(ctx, addr, at2.Token))
time.Sleep(10 * time.Millisecond)
assert.NoError(t, r.AddRawToken(ctx, addr, at1.Token))
gotP1, err := r.LookupToken(ctx, at1.Id)
require.NoError(t, err)
require.NotNil(t, gotP1)
gotP2, err := r.LookupToken(ctx, at2.Id)
require.NoError(t, err)
require.NotNil(t, gotP2)
assert.Greater(t, gotP1.LastAccessedTime, gotP2.LastAccessedTime)
}
func TestRepository_ListTokens(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
atMap := make(map[ringToken]*authtokens.AuthToken)
ktTokenCount := 15
for i := 0; i < ktTokenCount; i++ {
k := fmt.Sprintf("k%d", i)
t := fmt.Sprintf("t%d", i)
atMap[ringToken{k, t}] = at
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
for k, v := range atMap {
require.NoError(t, r.AddKeyringToken(ctx, addr, KeyringToken{
KeyringType: k.k,
TokenName: k.t,
AuthTokenId: v.Id,
}))
}
t.Run("no token", func(t *testing.T) {
gotP, err := r.listTokens(ctx, &user{Id: "tokenless"})
assert.NoError(t, err)
assert.Empty(t, gotP)
})
t.Run("many tokens", func(t *testing.T) {
gotAt, err := r.listTokens(ctx, u)
assert.NoError(t, err)
assert.Len(t, gotAt, 1)
gotKrt, err := r.listKeyringTokens(ctx, gotAt[0])
assert.NoError(t, err)
assert.Len(t, gotKrt, ktTokenCount)
})
}
func TestRepository_DeleteKeyringToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "u_1",
}
kt1 := KeyringToken{
TokenName: "t1",
KeyringType: "k1",
AuthTokenId: at1.Id,
}
boundaryAuthTokens := []*authtokens.AuthToken{at1}
atMap := map[ringToken]*authtokens.AuthToken{
{kt1.KeyringType, kt1.TokenName}: at1,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
t.Run("delete non existing", func(t *testing.T) {
assert.ErrorContains(t, r.deleteKeyringToken(ctx, KeyringToken{KeyringType: "Unknown", TokenName: "Unknown"}), "not found")
})
t.Run("delete existing", func(t *testing.T) {
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt1))
p, err := r.LookupToken(ctx, kt1.AuthTokenId)
require.NoError(t, err)
require.NotNil(t, p)
assert.NoError(t, r.deleteKeyringToken(ctx, kt1))
got, err := r.LookupToken(ctx, kt1.AuthTokenId)
require.NoError(t, err)
require.Nil(t, got)
})
}
func TestRepository_LookupToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "u_1",
}
kt := KeyringToken{
TokenName: "t1",
KeyringType: "k1",
AuthTokenId: at.Id,
}
boundaryAuthTokens := []*authtokens.AuthToken{at}
atMap := map[ringToken]*authtokens.AuthToken{
{kt.KeyringType, kt.TokenName}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
t.Run("empty token id", func(t *testing.T) {
p, err := r.LookupToken(ctx, "")
assert.ErrorContains(t, err, "auth token id is empty")
assert.Nil(t, p)
})
t.Run("not found", func(t *testing.T) {
p, err := r.LookupToken(ctx, "token")
assert.NoError(t, err)
assert.Nil(t, p)
})
t.Run("found", func(t *testing.T) {
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt))
p, err := r.LookupToken(ctx, kt.AuthTokenId)
assert.NoError(t, err)
assert.NotEmpty(t, p)
})
t.Run("withUpdateLastAccessedTime", func(t *testing.T) {
assert.NoError(t, r.AddKeyringToken(ctx, addr, kt))
time.Sleep(1 * time.Millisecond)
beforeP, err := r.LookupToken(ctx, kt.AuthTokenId, WithUpdateLastAccessedTime(true))
assert.NoError(t, err)
assert.NotEmpty(t, beforeP)
afterP, err := r.LookupToken(ctx, kt.AuthTokenId)
assert.NoError(t, err)
assert.NotEmpty(t, afterP)
assert.Greater(t, afterP.LastAccessedTime, beforeP.LastAccessedTime)
})
}
func TestRepository_RemoveStaleTokens(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
u := &user{
Id: "user",
Address: "address",
}
at1 := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
ExpirationTime: time.Now().Add(1 * time.Minute),
}
kt1 := KeyringToken{
TokenName: "t1",
KeyringType: "k1",
AuthTokenId: at1.Id,
}
at2 := &authtokens.AuthToken{
Id: "at_2",
Token: "at_2_token",
UserId: u.Id,
ExpirationTime: time.Now().Add(1 * time.Minute),
}
kt2 := KeyringToken{
TokenName: "t2",
KeyringType: "k2",
AuthTokenId: "at_2",
}
// this auth token expired a minute ago
at3 := &authtokens.AuthToken{
Id: "at_3",
Token: "at_3_token",
UserId: u.Id,
ExpirationTime: time.Now().Add(-(1 * time.Minute)),
}
kt3 := KeyringToken{
TokenName: "t3",
KeyringType: "k3",
AuthTokenId: "at_3",
}
boundaryAuthTokens := []*authtokens.AuthToken{at1, at2, at3}
atMap := map[ringToken]*authtokens.AuthToken{
{kt1.KeyringType, kt1.TokenName}: at1,
{kt2.KeyringType, kt2.TokenName}: at2,
{kt3.KeyringType, kt3.TokenName}: at3,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
assert.NoError(t, r.AddKeyringToken(ctx, u.Address, kt1))
assert.NoError(t, r.AddKeyringToken(ctx, u.Address, kt2))
assert.NoError(t, r.AddKeyringToken(ctx, u.Address, kt3))
staleTime := time.Now().Add(-(tokenStalenessLimit + 1*time.Hour))
freshTime := time.Now().Add(-(tokenStalenessLimit - 1*time.Hour))
freshAt := &AuthToken{
Id: at1.Id,
LastAccessedTime: freshTime,
}
_, err = r.rw.Update(ctx, freshAt, []string{"LastAccessedTime"}, nil)
require.NoError(t, err)
anotherFreshAt := &AuthToken{
Id: at3.Id,
LastAccessedTime: freshTime,
}
_, err = r.rw.Update(ctx, anotherFreshAt, []string{"LastAccessedTime"}, nil)
require.NoError(t, err)
staleAt := &AuthToken{
Id: at2.Id,
LastAccessedTime: staleTime,
}
_, err = r.rw.Update(ctx, staleAt, []string{"LastAccessedTime"}, nil)
require.NoError(t, err)
lAt, err := r.listTokens(ctx, u)
assert.NoError(t, err)
assert.Len(t, lAt, 3)
assert.NoError(t, r.cleanExpiredOrOrphanedAuthTokens(ctx))
lAt, err = r.listTokens(ctx, u)
assert.NoError(t, err)
assert.Len(t, lAt, 1)
assert.Equal(t, lAt[0].Id, at1.Id)
}
func TestCleanExpiredOrOrphanedAuthTokens_Errors(t *testing.T) {
ctx := context.Background()
err := cleanExpiredOrOrphanedAuthTokens(ctx, nil, &sync.Map{})
assert.Error(t, err)
assert.ErrorContains(t, err, "writer is nil")
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
err = cleanExpiredOrOrphanedAuthTokens(ctx, rw, &sync.Map{})
assert.Error(t, err)
assert.ErrorContains(t, err, "writer isn't part of an inflight transaction")
rw.DoTx(ctx, 1, db.ExpBackoff{}, func(_ db.Reader, writer db.Writer) error {
err := cleanExpiredOrOrphanedAuthTokens(ctx, writer, nil)
assert.Error(t, err)
assert.ErrorContains(t, err, "keyringless auth token map is nil")
return nil
})
}
func TestUpsertUserAndAuthToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
defaultAt := &authtokens.AuthToken{
Id: "at_123",
Token: "at_123_token",
UserId: "u_123",
}
rw.DoTx(ctx, 1, db.ExpBackoff{}, func(txReader db.Reader, txWriter db.Writer) error {
errorCases := []struct {
name string
reader db.Reader
writer db.Writer
addr string
at *authtokens.AuthToken
errorContains string
}{
{
name: "nil reader",
reader: nil,
writer: txWriter,
addr: "address",
at: defaultAt,
errorContains: "reader is nil",
},
{
name: "nil writer",
reader: txReader,
writer: nil,
addr: "address",
at: defaultAt,
errorContains: "writer is nil",
},
{
name: "writer not in tx",
reader: txReader,
writer: rw,
addr: "address",
at: defaultAt,
errorContains: "writer isn't part of an inflight transaction",
},
{
name: "empty address",
reader: txReader,
writer: txWriter,
addr: "",
at: defaultAt,
errorContains: "boundary address is empty",
},
{
name: "auth token is nil",
reader: txReader,
writer: txWriter,
addr: "address",
at: nil,
errorContains: "auth token is nil",
},
{
name: "auth token missing id",
reader: txReader,
writer: txWriter,
addr: "address",
at: &authtokens.AuthToken{
Token: "at_123_token",
UserId: "u_123",
},
errorContains: "auth token id is empty",
},
{
name: "auth token missing user id",
reader: txReader,
writer: txWriter,
addr: "address",
at: &authtokens.AuthToken{
Id: "at_123",
Token: "at_123_token",
},
errorContains: "auth token user id is empty",
},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
err := upsertUserAndAuthToken(ctx, tc.reader, tc.writer, tc.addr, tc.at)
assert.Error(t, err)
assert.ErrorContains(t, err, tc.errorContains)
})
}
return nil
})
}
+196
View File
@@ -0,0 +1,196 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
"github.com/hashicorp/go-bexpr"
)
type SearchableResource string
const (
Unknown SearchableResource = "unknown"
Targets SearchableResource = "targets"
Sessions SearchableResource = "sessions"
)
func (r SearchableResource) Valid() bool {
switch r {
case Targets, Sessions:
return true
}
return false
}
func ToSearchableResource(s string) SearchableResource {
switch {
case strings.EqualFold(s, string(Targets)):
return Targets
case strings.EqualFold(s, string(Sessions)):
return Sessions
}
return Unknown
}
// SearchParams contains the parameters for searching in the cache
type SearchParams struct {
// the name of the resource. eg. "targets" or "sessions"
Resource SearchableResource
// the auth token id for the user id that has resources synced to the cache
AuthTokenId string
// the optional mql query to use when searching the resources.
Query string
// the optional bexpr filter string that all results will be filtered by
Filter string
}
// SearchResult returns the results from searching the cache.
type SearchResult struct {
Targets []*targets.Target
Sessions []*sessions.Session
}
// SearchService is a domain service that can search across all resources in the
// cache.
type SearchService struct {
searchableResources map[SearchableResource]resourceSearcher
}
// QueryAndLister defines the methods needed for listing an querying the
// resources supported by the boundary frontend.
type QueryAndLister interface {
QueryTargets(context.Context, string, string) ([]*targets.Target, error)
ListTargets(context.Context, string) ([]*targets.Target, error)
QuerySessions(context.Context, string, string) ([]*sessions.Session, error)
ListSessions(context.Context, string) ([]*sessions.Session, error)
}
func NewSearchService(ctx context.Context, repo QueryAndLister) (*SearchService, error) {
const op = "cache.NewSearchService"
switch {
case util.IsNil(repo):
return nil, errors.New(ctx, errors.InvalidParameter, op, "repo is nil")
}
return &SearchService{
searchableResources: map[SearchableResource]resourceSearcher{
Targets: &resourceSearchFns[*targets.Target]{
list: repo.ListTargets,
query: repo.QueryTargets,
searchResult: func(t []*targets.Target) *SearchResult {
return &SearchResult{Targets: t}
},
},
Sessions: &resourceSearchFns[*sessions.Session]{
list: repo.ListSessions,
query: repo.QuerySessions,
searchResult: func(s []*sessions.Session) *SearchResult {
return &SearchResult{Sessions: s}
},
},
},
}, nil
}
// Search returns a SearchResult based on the provided SearchParams. If the
// SearchParams doesn't have a valid searchable resource or an auth token id
// an error is returned. If the auth token id is unrecognized or is associated
// with a user id which doesn't have any resources associated with it an empty
// SearchResult is returned. SearchResult will only have at most one field
// populated.
func (s *SearchService) Search(ctx context.Context, params SearchParams) (*SearchResult, error) {
const op = "cache.(SearchService).Search"
switch {
case !params.Resource.Valid():
return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid resource")
case params.AuthTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token id")
}
rSearcher, ok := s.searchableResources[params.Resource]
if !ok {
return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("resource name %q is not recognized", params.Resource))
}
resp, err := rSearcher.search(ctx, params)
if err != nil {
err = errors.Wrap(ctx, err, op)
}
return resp, err
}
// resourceSearchFns is a struct that collects all the functions needed to
// perform a search on a specific resource type.
type resourceSearchFns[T any] struct {
// list takes a context and an auth token and returns all resources for the
// user of that auth token. If the provided auth token is not in the cache
// an empty slice and no error is returned.
list func(context.Context, string) ([]T, error)
// query takes a context, an auth token, and a query string and returns all
// resources for that auth token that matches the provided query parameter.
// If the provided auth token is not in the cache an empty slice and no
// error is returned.
query func(context.Context, string, string) ([]T, error)
// searchResult is a function which provides a SearchResult based on the
// type of T. SearchResult contains different fields for the different
// resource types returned, so for example if T is *targets.Target the
// returned SearchResult will have it's "Targets" field populated so the
// searchResult should take the passed in paramater and assign it to the
// appropriate field in the SearchResult.
searchResult func([]T) *SearchResult
}
// resourceSearcher is an interface that only resourceSearchFns[T] is expected
// to satisfy. Specifying this interface allows the code to have a map with
// resourceSearchFns values which have different bound generic types.
type resourceSearcher interface {
search(ctx context.Context, p SearchParams) (*SearchResult, error)
}
// search will perform a query using the provided query string or a list if the
// provided query string is empty and filter than based on the provided filter.
// The results are tied to the user id associated with the provided auth token id.
// If the auth token id or the associated user are not in the cache no error
// is returned and the returned SearchResults will be empty.
// search implements searcher.
func (l *resourceSearchFns[T]) search(ctx context.Context, p SearchParams) (*SearchResult, error) {
const op = "daemon.(resourceSearchFns).search"
var found []T
var err error
switch p.Query {
case "":
found, err = l.list(ctx, p.AuthTokenId)
default:
found, err = l.query(ctx, p.AuthTokenId, p.Query)
}
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if p.Filter == "" {
return l.searchResult(found), nil
}
e, err := bexpr.CreateEvaluator(p.Filter, bexpr.WithTagName("json"))
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("couldn't build filter"), errors.WithCode(errors.InvalidParameter))
}
finalResults := make([]T, 0, len(found))
for _, item := range found {
if m, err := e.Evaluate(filterItem{item}); err == nil && m {
finalResults = append(finalResults, item)
}
}
return l.searchResult(finalResults), nil
}
type filterItem struct {
Item any `json:"item"`
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"sync"
"testing"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSearchService(t *testing.T) {
ctx := context.Background()
t.Run("nil repo", func(t *testing.T) {
ss, err := NewSearchService(ctx, nil)
assert.Error(t, err)
assert.ErrorContains(t, err, "repo is nil")
assert.Nil(t, ss)
})
t.Run("success", func(t *testing.T) {
s, err := cachedb.Open(ctx)
require.NoError(t, err)
r, err := NewRepository(ctx, s, &sync.Map{},
mapBasedAuthTokenKeyringLookup(nil),
sliceBasedAuthTokenBoundaryReader(nil))
assert.NoError(t, err)
ss, err := NewSearchService(ctx, r)
assert.NoError(t, err)
assert.NotNil(t, ss)
})
}
func TestSearch_Errors(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
r, err := NewRepository(ctx, s, &sync.Map{},
mapBasedAuthTokenKeyringLookup(nil),
sliceBasedAuthTokenBoundaryReader(nil))
assert.NoError(t, err)
ss, err := NewSearchService(ctx, r)
require.NoError(t, err)
require.NotNil(t, ss)
cases := []struct {
name string
params SearchParams
errorContains string
}{
{
name: "missing resource",
params: SearchParams{
Resource: "",
AuthTokenId: "at_1",
},
errorContains: "invalid resource",
},
{
name: "missing auth token id",
params: SearchParams{
Resource: "targets",
AuthTokenId: "",
},
errorContains: "missing auth token id",
},
{
name: "unrecognized resource",
params: SearchParams{
Resource: "unknown",
AuthTokenId: "at_1",
},
errorContains: "invalid resource",
},
{
name: "bad filter",
params: SearchParams{
Resource: "targets",
AuthTokenId: "at_1",
Filter: "unknown=filter?syntax!",
},
errorContains: "couldn't build filter",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res, err := ss.Search(ctx, tc.params)
assert.Error(t, err)
assert.ErrorContains(t, err, tc.errorContains)
assert.Nil(t, res)
})
}
}
func TestSearch(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
at := &AuthToken{
Id: "at_1",
UserId: "u_1",
}
{
u := &user{Id: at.UserId, Address: "address"}
rw := db.New(s)
require.NoError(t, rw.Create(ctx, u))
require.NoError(t, rw.Create(ctx, at))
targets := []any{
&Target{UserId: u.Id, Id: "t_1", Name: "one", Item: `{"id": "t_1", "name": "one"}`},
&Target{UserId: u.Id, Id: "t_2", Name: "two", Item: `{"id": "t_2", "name": "two"}`},
}
require.NoError(t, rw.CreateItems(ctx, targets))
sessions := []any{
&Session{UserId: u.Id, Id: "s_1", Endpoint: "one", Item: `{"id": "s_1", "endpoint": "one"}`},
&Session{UserId: u.Id, Id: "s_2", Endpoint: "two", Item: `{"id": "s_2", "endpoint": "two"}`},
}
require.NoError(t, rw.CreateItems(ctx, sessions))
}
r, err := NewRepository(ctx, s, &sync.Map{},
mapBasedAuthTokenKeyringLookup(nil),
sliceBasedAuthTokenBoundaryReader(nil))
assert.NoError(t, err)
ss, err := NewSearchService(ctx, r)
require.NoError(t, err)
require.NotNil(t, ss)
t.Run("List targets", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "targets",
AuthTokenId: at.Id,
})
assert.NoError(t, err)
assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{
{Id: "t_1", Name: "one"},
{Id: "t_2", Name: "two"},
}}, got)
})
t.Run("query targets", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "targets",
AuthTokenId: at.Id,
Query: `name="one"`,
})
assert.NoError(t, err)
assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{
{Id: "t_1", Name: "one"},
}}, got)
})
t.Run("query targets bad column", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "targets",
AuthTokenId: at.Id,
Query: `item % "one"`,
})
assert.Error(t, err)
assert.ErrorContains(t, err, `invalid column "item"`)
assert.Nil(t, got)
})
t.Run("Filter targets", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "targets",
AuthTokenId: at.Id,
Filter: `"/item/name" matches "one"`,
})
assert.NoError(t, err)
assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{
{Id: "t_1", Name: "one"},
}}, got)
})
t.Run("List sessions", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "sessions",
AuthTokenId: at.Id,
})
assert.NoError(t, err)
assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{
{Id: "s_1", Endpoint: "one"},
{Id: "s_2", Endpoint: "two"},
}}, got)
})
t.Run("query sessions", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "sessions",
AuthTokenId: at.Id,
Query: `endpoint="one"`,
})
assert.NoError(t, err)
assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{
{Id: "s_1", Endpoint: "one"},
}}, got)
})
t.Run("Filter sessions", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "sessions",
AuthTokenId: at.Id,
Filter: `"/item/endpoint" matches "one"`,
})
assert.NoError(t, err)
assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{
{Id: "s_1", Endpoint: "one"},
}}, got)
})
t.Run("unrecognized auth token", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: "targets",
AuthTokenId: "unrecognized",
})
assert.NoError(t, err)
assert.Equal(t, &SearchResult{Targets: []*targets.Target{}}, got)
})
}
+508
View File
@@ -0,0 +1,508 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"testing"
"time"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUser(t *testing.T) {
ctx := context.Background()
conn, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(conn)
t.Run("missing address", func(t *testing.T) {
u := &user{
Id: "u1",
}
assert.ErrorContains(t, rw.Create(ctx, &u), "constraint failed")
})
t.Run("missing id", func(t *testing.T) {
u := &user{
Address: "address",
}
assert.ErrorContains(t, rw.Create(ctx, &u), "constraint failed")
})
t.Run("create success", func(t *testing.T) {
u := &user{
Id: "created",
Address: "address",
}
assert.NoError(t, rw.Create(ctx, &u))
})
t.Run("update", func(t *testing.T) {
u := &user{
Id: "update",
Address: "address",
}
require.NoError(t, rw.Create(ctx, &u))
updatedU := u.clone()
updatedU.Address = "updated"
_, err := rw.Update(ctx, updatedU, []string{"address"}, nil)
require.NoError(t, err)
lookedUp := u.clone()
require.NoError(t, rw.LookupById(ctx, lookedUp))
assert.Equal(t, updatedU.Address, lookedUp.Address)
})
}
func TestUser_NoMoreTokens(t *testing.T) {
ctx := context.Background()
conn, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(conn)
u := &user{
Id: "userId",
Address: "address",
}
require.NoError(t, rw.Create(ctx, u))
tok1 := &AuthToken{
UserId: u.Id,
Id: "at_1",
}
require.NoError(t, rw.Create(ctx, tok1))
tok2 := &AuthToken{
UserId: u.Id,
Id: "at_2",
}
require.NoError(t, rw.Create(ctx, tok2))
assert.NoError(t, rw.LookupById(ctx, u))
// deleting a single token doesn't remove the user
_, err = rw.Exec(ctx, "delete from auth_token where id = ?", []any{tok1.Id})
require.NoError(t, err)
assert.NoError(t, rw.LookupById(ctx, u))
// deleting both tokens _does_ remove the user
_, err = rw.Exec(ctx, "delete from auth_token", nil)
require.NoError(t, err)
assert.True(t, errors.IsNotFoundError(rw.LookupById(ctx, u)))
}
func TestAuthToken_NoMoreKeyringTokens(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
u := &user{
Id: "userId",
Address: "address",
}
require.NoError(t, rw.Create(ctx, u))
at := &AuthToken{
UserId: u.Id,
Id: "at_1234567890",
}
require.NoError(t, rw.Create(ctx, at))
kt1 := &KeyringToken{
KeyringType: "k1",
TokenName: "n1",
AuthTokenId: at.Id,
}
require.NoError(t, rw.Create(ctx, kt1))
kt2 := &KeyringToken{
KeyringType: "k2",
TokenName: "n2",
AuthTokenId: at.Id,
}
require.NoError(t, rw.Create(ctx, kt2))
assert.NoError(t, rw.LookupById(ctx, u))
// deleting the keyring tokens doesn't remove the user
_, err = rw.Exec(ctx, "delete from keyring_token", nil)
require.NoError(t, err)
assert.NoError(t, rw.LookupById(ctx, at))
assert.NoError(t, rw.LookupById(ctx, u))
}
func TestAuthToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
u := &user{
Id: "userId",
Address: "address",
}
t.Run("no user foreign key constraint", func(t *testing.T) {
tok := &AuthToken{
UserId: u.Id,
Id: "at_1234567890",
}
require.ErrorContains(t, rw.Create(ctx, tok), "constraint failed")
})
require.NoError(t, rw.Create(ctx, u))
t.Run("no user id", func(t *testing.T) {
tok := &AuthToken{
Id: "at_1234567890",
}
require.ErrorContains(t, rw.Create(ctx, tok), "constraint failed")
})
t.Run("create", func(t *testing.T) {
tok := &AuthToken{
UserId: u.Id,
Id: "at_create",
}
before := time.Now().Truncate(1 * time.Millisecond)
require.NoError(t, rw.Create(ctx, tok))
require.NoError(t, rw.LookupById(ctx, tok))
assert.GreaterOrEqual(t, tok.LastAccessedTime, before)
})
t.Run("update", func(t *testing.T) {
tok := &AuthToken{
UserId: u.Id,
Id: "at_update",
}
require.NoError(t, rw.Create(ctx, tok))
tok.LastAccessedTime = time.Now().Add(-(24 * 365 * time.Hour))
n, err := rw.Update(ctx, tok, []string{"LastAccessedTime"}, nil)
assert.NoError(t, err)
assert.Equal(t, 1, n)
})
t.Run("delete user deletes token", func(t *testing.T) {
u := &user{
Id: "deletethis",
Address: "deleted",
}
require.NoError(t, rw.Create(ctx, u))
tok := &AuthToken{
UserId: u.Id,
Id: "at_deleted",
}
require.NoError(t, rw.Create(ctx, tok))
_, err = rw.Exec(ctx, "delete from user where id = ?", []any{u.Id})
require.True(t, errors.IsNotFoundError(rw.LookupById(ctx, tok)))
})
// TODO: When gorm sqlite driver fixes it's delete, use rw.Delete instead of the Exec.
// n, err := rw.Delete(ctx, p)
_, err = rw.Exec(ctx, "delete from auth_token", nil)
assert.NoError(t, err)
}
func TestKeyringToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
u := &user{
Id: "userId",
Address: "address",
}
require.NoError(t, rw.Create(ctx, u))
at := &AuthToken{
Id: "at_1",
UserId: u.Id,
}
t.Run("no token foreign key constraint", func(t *testing.T) {
kt := &KeyringToken{
KeyringType: "keyring",
TokenName: "token",
AuthTokenId: at.Id,
}
require.ErrorContains(t, rw.Create(ctx, kt), "constraint failed")
})
require.NoError(t, rw.Create(ctx, at))
t.Run("no auth token id", func(t *testing.T) {
tok := &KeyringToken{
KeyringType: "keyring",
TokenName: "token",
}
require.ErrorContains(t, rw.Create(ctx, tok), "constraint failed")
})
t.Run("create", func(t *testing.T) {
kt := &KeyringToken{
KeyringType: "keyring",
TokenName: "create",
AuthTokenId: at.Id,
}
require.NoError(t, rw.Create(ctx, kt))
require.NoError(t, rw.LookupById(ctx, kt))
})
t.Run("delete user deletes keyring token", func(t *testing.T) {
u := &user{
Id: "deletethis",
Address: "deleted",
}
require.NoError(t, rw.Create(ctx, u))
at := &AuthToken{
UserId: u.Id,
Id: "at_deleted",
}
require.NoError(t, rw.Create(ctx, at))
kt := &KeyringToken{
KeyringType: "keyring",
TokenName: "deleted",
AuthTokenId: at.Id,
}
require.NoError(t, rw.Create(ctx, kt))
require.NoError(t, rw.LookupById(ctx, kt))
_, err = rw.Exec(ctx, "delete from user where id = ?", []any{u.Id})
require.True(t, errors.IsNotFoundError(rw.LookupById(ctx, kt)))
})
t.Run("delete auth token deletes keyring token", func(t *testing.T) {
u := &user{
Id: "deletethis",
Address: "deleted",
}
require.NoError(t, rw.Create(ctx, u))
at := &AuthToken{
UserId: u.Id,
Id: "at_deleted",
}
require.NoError(t, rw.Create(ctx, at))
kt := &KeyringToken{
KeyringType: "keyring",
TokenName: "deleted",
AuthTokenId: at.Id,
}
require.NoError(t, rw.Create(ctx, kt))
require.NoError(t, rw.LookupById(ctx, kt))
_, err = rw.Exec(ctx, "delete from auth_token where id = ?", []any{at.Id})
require.True(t, errors.IsNotFoundError(rw.LookupById(ctx, kt)))
})
// TODO: When gorm sqlite driver fixes it's delete, use rw.Delete instead of the Exec.
// n, err := rw.Delete(ctx, p)
_, err = rw.Exec(ctx, "delete from keyring_token", nil)
assert.NoError(t, err)
}
func TestTarget(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
addr := "boundary"
userId := "u_12345"
u := &user{
Id: userId,
Address: addr,
}
require.NoError(t, rw.Create(ctx, u))
t.Run("target without user id", func(t *testing.T) {
unknownTarget := &Target{
Id: "tssh_1234567890",
Name: "target",
Description: "target desc",
Address: "some address",
Item: "{id:'tssh_1234567890'}",
}
require.ErrorContains(t, rw.Create(ctx, unknownTarget), "constraint failed")
})
t.Run("target actions", func(t *testing.T) {
target := &Target{
UserId: u.Id,
Id: "tssh_1234567890",
Name: "target",
Description: "target desc",
Address: "some address",
Item: "{id:'tssh_1234567890'}",
}
require.NoError(t, rw.Create(ctx, target))
require.NoError(t, rw.LookupById(ctx, target))
target.Address = "new address"
n, err := rw.Update(ctx, target, []string{"address"}, nil)
assert.NoError(t, err)
assert.Equal(t, 1, n)
// TODO: Once the sqlite driver properly builds the delete query call
// n, err = rw.Delete(ctx, target) instead of the Exec call
n, err = rw.Exec(ctx, "delete from target where (user_id, id) IN (values (?, ?))",
[]any{target.UserId, target.Id})
assert.NoError(t, err)
assert.Equal(t, 1, n)
})
t.Run("lookup a target", func(t *testing.T) {
target := &Target{
UserId: u.Id,
Id: "tssh_1234567890",
Name: "target",
Description: "target desc",
Address: "some address",
Item: "{id:'tssh_1234567890'}",
}
require.NoError(t, rw.Create(ctx, target))
lookTar := &Target{
UserId: target.UserId,
Id: target.Id,
}
assert.NoError(t, rw.LookupById(ctx, lookTar))
assert.NotNil(t, lookTar)
// cleanup the targets
_, err := rw.Exec(ctx, "delete from target", nil)
require.NoError(t, err)
})
t.Run("deleting the user deletes the target", func(t *testing.T) {
target := &Target{
UserId: u.Id,
Id: "tssh_1234567890",
Name: "target",
Description: "target desc",
Address: "some address",
Item: "{id:'tssh_1234567890'}",
}
require.NoError(t, rw.Create(ctx, target))
// Deleting the user deletes the target
// TODO: Once the sqlite driver supports proper deletes change from the
// Exec call to .Delete
// n, err := rw.Delete(ctx, &u)
n, err := rw.Exec(ctx, "delete from user where id = ?", []any{userId})
require.NoError(t, err)
require.Equal(t, 1, n)
lookTar := &Target{
UserId: target.UserId,
Id: target.Id,
}
assert.ErrorContains(t, rw.LookupById(ctx, lookTar), "not found")
})
}
func TestSession(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
rw := db.New(s)
addr := "boundary"
userId := "u_12345"
u := &user{
Id: userId,
Address: addr,
}
require.NoError(t, rw.Create(ctx, u))
t.Run("session without user id", func(t *testing.T) {
unknownSess := &Session{
Id: "sess_1234567890",
Item: "{id:'sess_1234567890'}",
}
require.ErrorContains(t, rw.Create(ctx, unknownSess), "FOREIGN KEY constraint")
})
t.Run("session actions", func(t *testing.T) {
session := &Session{
UserId: u.Id,
Id: "s_1234567890",
Endpoint: "endpoint",
Item: "{id:'s_1234567890'}",
}
require.NoError(t, rw.Create(ctx, session))
require.NoError(t, rw.LookupById(ctx, session))
session.Endpoint = "new endpoint"
n, err := rw.Update(ctx, session, []string{"endpoint"}, nil)
assert.NoError(t, err)
assert.Equal(t, 1, n)
// TODO: Once the sqlite driver properly builds the delete query call
// n, err = rw.Delete(ctx, session) instead of the Exec call
n, err = rw.Exec(ctx, "delete from session where (user_id, id) IN (values (?, ?))",
[]any{session.UserId, session.Id})
assert.NoError(t, err)
assert.Equal(t, 1, n)
})
t.Run("lookup a session", func(t *testing.T) {
session := &Session{
UserId: u.Id,
Id: "s_1234567890",
Endpoint: "endpoint",
Item: "{id:'s_1234567890'}",
}
require.NoError(t, rw.Create(ctx, session))
lookSess := &Session{
UserId: u.Id,
Id: session.Id,
}
assert.NoError(t, rw.LookupById(ctx, lookSess))
assert.NotNil(t, lookSess)
// cleanup the sessions
_, err := rw.Exec(ctx, "delete from session", nil)
require.NoError(t, err)
})
t.Run("deleting the user deletes the session", func(t *testing.T) {
session := &Session{
UserId: u.Id,
Id: "s_1234567890",
Endpoint: "endpoint",
Item: "{id:'s_1234567890'}",
}
require.NoError(t, rw.Create(ctx, session))
// Deleting the user deletes the session
// TODO: Once the sqlite driver supports proper deletes change from the
// Exec call to .Delete
// n, err := rw.Delete(ctx, &u)
n, err := rw.Exec(ctx, "delete from user where id = ?", []any{userId})
require.NoError(t, err)
require.Equal(t, 1, n)
lookSess := &Session{
UserId: session.UserId,
Id: session.Id,
}
assert.ErrorContains(t, rw.LookupById(ctx, lookSess), "not found")
})
}
@@ -0,0 +1,7 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
/*
Package daemon contains the logic for managing the client cache daemon.
*/
package daemon
@@ -0,0 +1,25 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/boundary/api"
)
func writeError(w http.ResponseWriter, msg string, s int) {
status := http.StatusText(s)
b, err := json.Marshal(&api.Error{
Kind: status,
Message: msg,
})
if err != nil {
http.Error(w, fmt.Sprintf("Unable to marshal error {Kind: %s, Message: %q} into api error format: %s", status, msg, err.Error()), http.StatusInternalServerError)
return
}
http.Error(w, string(b), s)
}
@@ -0,0 +1,59 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"github.com/hashicorp/boundary/internal/errors"
)
const (
sockAddr = "socket/daemon.sock"
// Currently we only allow the same user that started the boundary daemon
// to connect to the socket to make requests to it.
// In linux, sockets visible to the FS honor the perms of the dir they are in.
// To create a new socket we must have read/write/list(execute) permissions on
// the directory it is being created in.
socketDirPerms = 0o700
// To connect to a socket it must have read/write permissions
socketPerms = 0o600
)
// listener provides a Listener on the daemon unix socket.
func listener(ctx context.Context, path string) (net.Listener, error) {
const op = "daemon.listener"
socketName := filepath.Join(path, sockAddr)
if err := os.Remove(socketName); err != nil {
// If the socket existed before and wasn't cleaned up delete it now.
if !os.IsNotExist(err) {
return nil, errors.Wrap(ctx, err, op)
}
}
socketPath := filepath.Dir(socketName)
if err := os.MkdirAll(socketPath, socketDirPerms); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to create boundary directory"))
}
l, err := net.Listen("unix", socketName)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Failed listening"))
}
if err := os.Chmod(socketName, socketPerms); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("changing socket permissiosn"))
}
return l, nil
}
// SocketAddress returns the unix socket filename with a 'unix://' prefix.
// The returned value can be passed directly into the api.Client
func SocketAddress(path string) string {
return fmt.Sprintf("unix://%s", filepath.Join(path, sockAddr))
}
@@ -0,0 +1,39 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
// +build !windows
package daemon
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TODO: Write a test for this that can run on windows.
func TestListenerSocketPermissions(t *testing.T) {
ctx := context.Background()
path, err := os.MkdirTemp("", "*")
require.NoError(t, err)
l, err := listener(ctx, path)
require.NoError(t, err)
socketFile := l.Addr().String()
fi, err := os.Stat(socketFile)
require.NoError(t, err)
assert.Equal(t, fi.Mode().Type(), os.ModeSocket)
assert.Equal(t, fs.FileMode(0o600), fi.Mode().Perm(), "permissions were ", fi.Mode().Perm().String())
socketDirName := filepath.Dir(socketFile)
di, err := os.Stat(socketDirName)
require.NoError(t, err)
assert.Equal(t, di.Mode().Type(), os.ModeDir)
assert.Equal(t, fs.FileMode(0o700), di.Mode().Perm(), "permissions were ", fi.Mode().Perm().String())
}
@@ -0,0 +1,60 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"testing"
"github.com/hashicorp/boundary/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Note: the name of this test must remain short because the temp dir created
// includes the name of the test and there is a 108 character limit in allowed
// unix socket path names.
func TestListenComms(t *testing.T) {
ctx := context.Background()
path := t.TempDir()
socketListener, err := listener(ctx, path)
require.NoError(t, err)
mux := http.NewServeMux()
payload := "Hello test"
mux.HandleFunc("/v1/test", func(w http.ResponseWriter, r *http.Request) {
_, err = fmt.Fprint(w, payload)
require.NoError(t, err)
})
srv := &http.Server{
Handler: mux,
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
assert.ErrorIs(t, srv.Serve(socketListener), http.ErrServerClosed)
}()
client, err := api.NewClient(nil)
require.NoError(t, err)
require.NoError(t, client.SetAddr(SocketAddress(path)))
client.SetToken("")
req, err := client.NewRequest(ctx, "GET", "/test", nil)
require.NoError(t, err)
resp, err := client.Do(req)
assert.NoError(t, err)
got, err := io.ReadAll(resp.HttpResponse().Body)
require.NoError(t, err)
assert.Equal(t, string(got), payload)
require.NoError(t, srv.Close())
wg.Wait()
}
@@ -0,0 +1,49 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
)
type options struct {
withDebug bool
withBoundaryTokenReaderFunc cache.BoundaryTokenReaderFn
}
// Option - how options are passed as args
type Option func(*options) error
func getDefaultOptions() options {
return options{}
}
func getOpts(opt ...Option) (options, error) {
opts := getDefaultOptions()
for _, o := range opt {
if err := o(&opts); err != nil {
return opts, err
}
}
return opts, nil
}
// WithDebug provides an optional debug flag.
func WithDebug(_ context.Context, debug bool) Option {
return func(o *options) error {
o.withDebug = debug
return nil
}
}
// WithBoundaryTokenReaderFunc provides an option for specifying a BoundaryTokenReaderFn
func WithBoundaryTokenReaderFunc(_ context.Context, fn cache.BoundaryTokenReaderFn) Option {
return func(o *options) error {
o.withBoundaryTokenReaderFunc = fn
return nil
}
}
@@ -0,0 +1,46 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"testing"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetOpts(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("default", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
testOpts := options{}
assert.Equal(t, opts, testOpts)
})
t.Run("WithDebug", func(t *testing.T) {
opts, err := getOpts(WithDebug(ctx, true))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withDebug = true
assert.Equal(t, opts, testOpts)
})
t.Run("WithBoundaryTokenReaderFunc", func(t *testing.T) {
var f cache.BoundaryTokenReaderFn = func(ctx context.Context, addr, token string) (*authtokens.AuthToken, error) {
return nil, nil
}
opts, err := getOpts(WithBoundaryTokenReaderFunc(ctx, f))
require.NoError(t, err)
assert.NotNil(t, opts.withBoundaryTokenReaderFunc)
opts.withBoundaryTokenReaderFunc = nil
testOpts := getDefaultOptions()
assert.Equal(t, opts, testOpts)
})
}
@@ -0,0 +1,110 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
)
// SearchResult is the struct returned to search requests.
type SearchResult struct {
Targets []*targets.Target `json:",omitempty"`
Sessions []*sessions.Session `json:",omitempty"`
}
const (
filterKey = "filter"
queryKey = "query"
resourceKey = "resource"
boundaryAddrKey = "boundary_addr"
authTokenIdKey = "auth_token_id"
)
func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository) (http.HandlerFunc, error) {
const op = "daemon.newSearchHandlerFunc"
switch {
case util.IsNil(repo):
return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is missing")
}
s, err := cache.NewSearchService(ctx, repo)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
resource := r.URL.Query().Get(resourceKey)
authTokenId := r.URL.Query().Get(authTokenIdKey)
searchableResource := cache.ToSearchableResource(resource)
switch {
case resource == "":
writeError(w, "resource is a required field but was empty", http.StatusBadRequest)
return
case !searchableResource.Valid():
writeError(w, "provided resource is not a valid searchable resource", http.StatusBadRequest)
return
case authTokenId == "":
writeError(w, fmt.Sprintf("%s is a required field but was empty", authTokenIdKey), http.StatusBadRequest)
return
}
t, err := repo.LookupToken(ctx, authTokenId, cache.WithUpdateLastAccessedTime(true))
if err != nil || t == nil {
writeError(w, "Forbidden", http.StatusForbidden)
return
}
query := r.URL.Query().Get(queryKey)
filter := r.URL.Query().Get(filterKey)
res, err := s.Search(ctx, cache.SearchParams{
AuthTokenId: authTokenId,
Resource: searchableResource,
Query: query,
Filter: filter,
})
if err != nil {
switch {
case errors.Match(errors.T(errors.InvalidParameter), err):
writeError(w, err.Error(), http.StatusBadRequest)
default:
writeError(w, err.Error(), http.StatusInternalServerError)
}
return
}
if res == nil {
writeError(w, "nil SearchResult generated", http.StatusInternalServerError)
return
}
apiRes := toApiResult(res)
j, err := json.Marshal(apiRes)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(j)
}, nil
}
// toApiResult converts a domain search result to an api search result
func toApiResult(sr *cache.SearchResult) *SearchResult {
return &SearchResult{
Targets: sr.Targets,
Sessions: sr.Sessions,
}
}
@@ -0,0 +1,453 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/cmd/base/logging"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/util"
"github.com/hashicorp/boundary/version"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-secure-stdlib/parseutil"
)
// Commander is an interface that provides a way to get an apiClient
// and retrieve the keyring and token information used by a command.
type Commander interface {
ClientProvider
DiscoverKeyringTokenInfo() (string, string, error)
ReadTokenFromKeyring(keyringType, tokenName string) *authtokens.AuthToken
}
// ClientProvider is an interface that provides an api.Client
type ClientProvider interface {
Client(opt ...base.Option) (*api.Client, error)
}
type CacheServer struct {
conf *Config
infoKeys []string
info map[string]string
storeUrl string
store *db.DB
tickerWg *sync.WaitGroup
httpSrv *http.Server
shutdownOnce *sync.Once
}
type Config struct {
ContextCancel context.CancelFunc
RefreshIntervalSeconds int64
DatabaseUrl string
StoreDebug bool
LogLevel string
LogFormat string
LogWriter io.Writer
DotDirectory string
}
func (sc *Config) validate(ctx context.Context) error {
const op = "daemon.(serverConfig).validate"
switch {
case util.IsNil(sc.LogWriter):
return errors.New(ctx, errors.InvalidParameter, op, "missing log writter")
case util.IsNil(sc.ContextCancel):
return errors.New(ctx, errors.InvalidParameter, op, "missing contextCancel")
case sc.DotDirectory == "":
return errors.New(ctx, errors.InvalidParameter, op, "missing dot directory")
}
return nil
}
// can be called before eventing is setup
func New(ctx context.Context, conf *Config) (*CacheServer, error) {
const op = "daemon.newServer"
if err := conf.validate(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
s := &CacheServer{
conf: conf,
info: make(map[string]string),
infoKeys: make([]string, 0, 20),
tickerWg: new(sync.WaitGroup),
shutdownOnce: new(sync.Once),
}
if err := s.setupLogging(ctx, conf.LogWriter); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return s, nil
}
func (s *CacheServer) Shutdown(ctx context.Context) error {
const op = "daemon.(cacheServer).Shutdown"
var shutdownErr error
s.shutdownOnce.Do(func() {
if s.conf.ContextCancel != nil {
s.conf.ContextCancel()
}
srvCtx, srvCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer srvCancel()
err := s.httpSrv.Shutdown(srvCtx)
if err != nil {
shutdownErr = fmt.Errorf("error shutting down server: %w", err)
return
}
s.tickerWg.Wait()
event.WriteSysEvent(context.Background(), op, "daemon server shutdown")
if err := event.SysEventer().FlushNodes(context.Background()); err != nil {
shutdownErr = fmt.Errorf("error flushing eventer nodes: %w", err)
return
}
})
return shutdownErr
}
func defaultBoundaryTokenReader(ctx context.Context, cp ClientProvider) (cache.BoundaryTokenReaderFn, error) {
const op = "daemon.defaultBoundaryTokenReader"
switch {
case util.IsNil(cp):
return nil, errors.New(ctx, errors.InvalidParameter, op, "client provider is nil")
}
return func(ctx context.Context, addr, tok string) (*authtokens.AuthToken, error) {
switch {
case addr == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "address is missing")
case tok == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token is missing")
}
atIdParts := strings.SplitN(tok, "_", 4)
if len(atIdParts) != 3 {
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token is malformed")
}
atId := strings.Join(atIdParts[:cache.AuthTokenIdSegmentCount], "_")
c, err := cp.Client()
if err != nil {
return nil, err
}
c.SetAddr(addr)
c.SetToken(tok)
atClient := authtokens.NewClient(c)
at, err := atClient.Read(ctx, atId)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return at.GetItem(), nil
}, nil
}
// Serve will fire up the refresh goroutine and the caching API http server as a
// daemon. The daemon bits are included so it's easy for CLI cmds to start the
// a cache server
func (s *CacheServer) Serve(ctx context.Context, cmd Commander, opt ...Option) error {
const op = "daemon.(cacheServer).Serve"
switch {
case util.IsNil(ctx):
return errors.New(ctx, errors.InvalidParameter, op, "context is missing")
}
opts, err := getOpts(opt...)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if opts.withBoundaryTokenReaderFunc == nil {
opts.withBoundaryTokenReaderFunc, err = defaultBoundaryTokenReader(ctx, cmd)
if err != nil {
return errors.Wrap(ctx, err, op)
}
}
l, err := listener(ctx, s.conf.DotDirectory)
if err != nil {
return errors.Wrap(ctx, err, op)
}
s.info["Listening address"] = l.Addr().String()
s.infoKeys = append(s.infoKeys, "Listening address")
s.info["Store debug"] = strconv.FormatBool(s.conf.StoreDebug)
s.infoKeys = append(s.infoKeys, "Store debug")
if s.store, s.storeUrl, err = openStore(ctx, s.conf.DatabaseUrl, s.conf.StoreDebug); err != nil {
return errors.Wrap(ctx, err, op)
}
s.info["Database URL"] = s.storeUrl
s.infoKeys = append(s.infoKeys, "Database URL")
s.printInfo(ctx)
repo, err := cache.NewRepository(ctx, s.store, &sync.Map{}, cmd.ReadTokenFromKeyring, opts.withBoundaryTokenReaderFunc)
if err != nil {
return errors.Wrap(ctx, err, op)
}
// If we have a token info already, add it to the repository immediately so
// it can start to get updated.
func() {
client, err := cmd.Client()
if err != nil {
event.WriteError(ctx, op, err)
return
}
krType, tokName, err := cmd.DiscoverKeyringTokenInfo()
if err != nil {
event.WriteError(ctx, op, err)
return
}
if at := cmd.ReadTokenFromKeyring(krType, tokName); at != nil {
if err := repo.AddKeyringToken(ctx, client.Addr(), cache.KeyringToken{KeyringType: krType, TokenName: tokName, AuthTokenId: at.Id}); err != nil {
event.WriteError(ctx, op, err)
return
}
}
}()
refreshService, err := cache.NewRefreshService(ctx, repo)
if err != nil {
return errors.Wrap(ctx, err, op)
}
tic, err := newRefreshTicker(ctx, s.conf.RefreshIntervalSeconds, refreshService)
if err != nil {
return errors.Wrap(ctx, err, op)
}
tickingCtx, cancel := context.WithCancel(ctx)
defer cancel()
var tickerWg sync.WaitGroup
tickerWg.Add(1)
go func() {
defer tickerWg.Done()
tic.start(tickingCtx)
}()
mux := http.NewServeMux()
searchTargetsFn, err := newSearchHandlerFunc(ctx, repo)
if err != nil {
return errors.Wrap(ctx, err, op)
}
mux.HandleFunc("/v1/search", searchTargetsFn)
tokenFn, err := newTokenHandlerFunc(ctx, repo, tic)
if err != nil {
return errors.Wrap(ctx, err, op)
}
mux.HandleFunc("/v1/tokens", tokenFn)
stopFn, err := newStopHandlerFunc(ctx, s.conf.ContextCancel)
if err != nil {
return errors.Wrap(ctx, err, op)
}
mux.Handle("/v1/stop", versionEnforcement(stopFn))
logger, err := event.SysEventer().StandardLogger(ctx, "daemon.serve: ", event.ErrorType)
if err != nil {
return errors.Wrap(ctx, err, op)
}
s.httpSrv = &http.Server{
Handler: mux,
ErrorLog: logger,
BaseContext: func(net.Listener) context.Context {
return ctx
},
}
if err = s.httpSrv.Serve(l); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) {
event.WriteSysEvent(ctx, op, "error closing server", "err", err.Error())
}
cancel()
tickerWg.Wait()
return nil
}
func (s *CacheServer) printInfo(ctx context.Context) {
const op = "daemon.(cacheServer).printInfo"
verInfo := version.Get()
if verInfo.Version != "" {
s.infoKeys = append(s.infoKeys, "version")
s.info["version"] = verInfo.FullVersionNumber(false)
}
if verInfo.Revision != "" {
s.info["version sha"] = strings.Trim(verInfo.Revision, "'")
s.infoKeys = append(s.infoKeys, "version sha")
}
s.infoKeys = append(s.infoKeys, "cgo")
s.info["cgo"] = "disabled"
if version.CgoEnabled {
s.info["cgo"] = "enabled"
}
// Server configuration output
padding := 0
for _, k := range s.infoKeys {
currPadding := padding - len(k)
if currPadding < 2 {
padding = len(k) + 2
}
}
sort.Strings(s.infoKeys)
output := []string{}
output = append(output, "==> cache configuration:\n")
for _, k := range s.infoKeys {
output = append(output, fmt.Sprintf(
"%s%s: %s",
strings.Repeat(" ", padding-len(k)),
strings.Title(k),
s.info[k]))
}
output = append(output, "")
// Output the header that the server has started
output = append(output, "==> cache started! Log data will stream in below:\n")
event.WriteSysEvent(ctx, op, strings.Join(output, "\n"))
}
func (s *CacheServer) setupLogging(ctx context.Context, w io.Writer) error {
const op = "daemon.(Command).setupLogging"
switch {
case util.IsNil(w):
return errors.New(ctx, errors.InvalidParameter, op, "log writer is nil")
}
logFormat := logging.StandardFormat
if s.conf.LogFormat != "" {
var err error
logFormat, err = logging.ParseLogFormat(s.conf.LogFormat)
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
}
logLevel := strings.ToLower(strings.TrimSpace(s.conf.LogLevel))
if logLevel == "" {
logLevel = "info"
}
// Set up logging
// Set level based off text value
var level hclog.Level
switch logLevel {
case "trace":
level = hclog.Trace
case "debug":
level = hclog.Debug
case "notice", "info":
level = hclog.Info
case "warn", "warning":
level = hclog.Warn
case "err", "error":
level = hclog.Error
default:
return fmt.Errorf("%s: unknown log level: %s", op, logLevel)
}
var logLock sync.Mutex
logger := hclog.New(&hclog.LoggerOptions{
Output: w,
Level: level,
JSONFormat: logFormat == logging.JSONFormat,
Mutex: &logLock,
})
if err := event.InitFallbackLogger(logger); err != nil {
return fmt.Errorf("%s: %w", op, err)
}
s.info["log level"] = level.String()
s.infoKeys = append(s.infoKeys, "log level")
s.info["log format"] = logFormat.String()
s.infoKeys = append(s.infoKeys, "log format")
var err error
if err = setupEventing(ctx, logger, &logLock, logFormat, w); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
func setupEventing(ctx context.Context, logger hclog.Logger, serializationLock *sync.Mutex, logFormat logging.LogFormat, w io.Writer) error {
const op = "daemon.setupEventing"
switch {
case util.IsNil(logger):
return errors.New(ctx, errors.InvalidParameter, op, "logger is missing")
case util.IsNil(serializationLock):
return errors.New(ctx, errors.InvalidParameter, op, "serialization lock is missing")
}
serverName, err := os.Hostname()
if err != nil {
return fmt.Errorf("%s: unable to determine hostname: %w", op, err)
}
serverName = fmt.Sprintf("%s/%s", serverName, "cache")
var sinkFormat event.SinkFormat
switch logFormat {
case logging.JSONFormat:
sinkFormat = event.JSONSinkFormat
default:
sinkFormat = event.TextHclogSinkFormat
}
cfg := event.EventerConfig{
AuditEnabled: false,
ObservationsEnabled: true,
SysEventsEnabled: true,
Sinks: []*event.SinkConfig{
{
Name: "default",
EventTypes: []event.Type{event.EveryType},
Format: sinkFormat,
Type: event.WriterSink,
WriterConfig: &event.WriterSinkTypeConfig{
Writer: w,
},
},
},
}
if err := event.InitSysEventer(logger, serializationLock, serverName, event.WithEventerConfig(&cfg)); err != nil {
return fmt.Errorf("%s: unable to initialize system eventer: %w", op, err)
}
return nil
}
func openStore(ctx context.Context, url string, flagDebugStore bool) (*db.DB, string, error) {
const op = "daemon.openStore"
var err error
opts := []cachedb.Option{cachedb.WithDebug(flagDebugStore)}
switch {
case url != "":
url, err = parseutil.ParsePath(url)
if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) {
return nil, "", errors.Wrap(ctx, err, op)
}
opts = append(opts, cachedb.WithUrl(url))
}
store, err := cachedb.Open(ctx, opts...)
if err != nil {
return nil, "", errors.Wrap(ctx, err, op)
}
return store, url, nil
}
@@ -0,0 +1,97 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"testing"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/daemon/controller"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Note: the name of this test must remain short because the temp dir created
// includes the name of the test and there is a 108 character limit in allowed
// unix socket path names.
func TestDefaultBoundaryTokenReader(t *testing.T) {
ctx := context.Background()
t.Run("nil client provider", func(t *testing.T) {
resFn, err := defaultBoundaryTokenReader(ctx, nil)
assert.Error(t, err)
assert.ErrorContains(t, err, "client provider is nil")
assert.Nil(t, resFn)
})
tc := controller.NewTestController(t, nil)
cp := fakeClientProvider{tc}
cases := []struct {
name string
address string
token string
errContains string
}{
{
name: "success",
address: tc.ApiAddrs()[0],
token: tc.Token().Token,
errContains: "",
},
{
name: "empty address",
address: "",
token: "at_123_testtoken",
errContains: "address is missing",
},
{
name: "empty token",
address: tc.ApiAddrs()[0],
token: "",
errContains: "auth token is missing",
},
{
name: "malformed token to many sections",
address: tc.ApiAddrs()[0],
token: "at_123_ignoredtoken_tomanysections",
errContains: "auth token is malformed",
},
{
name: "malformed token to few sections",
address: tc.ApiAddrs()[0],
token: "at_123",
errContains: "auth token is malformed",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
retFn, err := defaultBoundaryTokenReader(ctx, cp)
require.NoError(t, err)
require.NotNil(t, retFn)
at, err := retFn(ctx, tc.address, tc.token)
switch tc.errContains {
case "":
assert.NoError(t, err)
assert.NotNil(t, at)
default:
assert.Error(t, err)
assert.ErrorContains(t, err, tc.errContains)
assert.Nil(t, at)
}
})
}
}
type fakeClientProvider struct {
*controller.TestController
}
func (fcp fakeClientProvider) Client(opt ...base.Option) (*api.Client, error) {
return fcp.TestController.Client(), nil
}
@@ -0,0 +1,31 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"net/http"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
)
// newStopHandlerFunc creates a handler that cancels the server context.
// This is only expected to be used on windows currently since it cannot use
// POSIX signals to perform a graceful shutdown.
func newStopHandlerFunc(ctx context.Context, cancelFn context.CancelFunc) (http.HandlerFunc, error) {
const op = "daemon.newSearchTargetsHandlerFunc"
switch {
case util.IsNil(cancelFn):
return nil, errors.New(ctx, errors.InvalidParameter, op, "server cancelFn is missing")
}
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, "only method POST allowed", http.StatusMethodNotAllowed)
}
w.WriteHeader(http.StatusNoContent)
cancelFn()
}, nil
}
@@ -0,0 +1,45 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStopHandler(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ph, err := newStopHandlerFunc(ctx, cancel)
require.NoError(t, err)
mux := http.NewServeMux()
mux.HandleFunc("/v1/stop", ph)
t.Run("get", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/stop", nil)
mux.ServeHTTP(rec, req)
assert.Equal(t, http.StatusMethodNotAllowed, rec.Result().StatusCode)
})
t.Run("delete", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/v1/stop", nil)
mux.ServeHTTP(rec, req)
assert.Equal(t, http.StatusMethodNotAllowed, rec.Result().StatusCode)
})
t.Run("success", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/stop", nil)
mux.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNoContent, rec.Result().StatusCode)
assert.Error(t, ctx.Err())
})
}
@@ -0,0 +1,93 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"io"
"sync"
"testing"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/stretchr/testify/require"
)
type TestServer struct {
*CacheServer
dotDir string
cmd Commander
}
// NewTestServer creates a test cache server using reasonable defaults for
// tests. Supports the option WithDebugFlag to enable debug output for sql
func NewTestServer(t *testing.T, cmd Commander, opt ...Option) *TestServer {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
opts, err := getOpts(opt...)
require.NoError(t, err)
dotDir := t.TempDir()
cfg := &Config{
ContextCancel: cancel,
RefreshIntervalSeconds: DefaultRefreshIntervalSeconds,
StoreDebug: opts.withDebug,
LogWriter: io.Discard,
DotDirectory: dotDir,
}
s, err := New(ctx, cfg)
require.NoError(t, err)
return &TestServer{
CacheServer: s,
dotDir: dotDir,
cmd: cmd,
}
}
// BaseDotDir returns the base directory in which the daemon socket is
// created.
func (s *TestServer) BaseDotDir() string {
return s.dotDir
}
// Serve runs the cache server. This is a blocking call and returns when the
// server is shutdown or stops for any other reason.
func (s *TestServer) Serve(t *testing.T, opt ...Option) error {
t.Helper()
ctx := context.Background()
t.Cleanup(func() {
s.Shutdown(ctx)
})
return s.CacheServer.Serve(ctx, s.cmd, opt...)
}
// AddResources adds targets to the cache for the provided address, token name,
// and keyring type. They token info must already be known to the server.
func (s *TestServer) AddResources(t *testing.T, p *authtokens.AuthToken, tars []*targets.Target, sess []*sessions.Session, atReadFn cache.BoundaryTokenReaderFn) {
t.Helper()
ctx := context.Background()
r, err := cache.NewRepository(ctx, s.CacheServer.store, &sync.Map{}, s.cmd.ReadTokenFromKeyring, atReadFn)
require.NoError(t, err)
tarFn := func(ctx context.Context, _, tok string) ([]*targets.Target, error) {
if tok != p.Token {
return nil, nil
}
return tars, nil
}
sessFn := func(ctx context.Context, _, tok string) ([]*sessions.Session, error) {
if tok != p.Token {
return nil, nil
}
return sess, nil
}
rs, err := cache.NewRefreshService(ctx, r)
require.NoError(t, err)
require.NoError(t, rs.Refresh(ctx, cache.WithTargetRetrievalFunc(tarFn), cache.WithSessionRetrievalFunc(sessFn)))
}
@@ -0,0 +1,82 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"time"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/util"
)
const (
DefaultRefreshIntervalSeconds = 5 * 60
defaultRefreshInterval = DefaultRefreshIntervalSeconds * time.Second
)
type refreshService interface {
Refresh(context.Context, ...cache.Option) error
}
type refreshTicker struct {
tickerCtx context.Context
refreshInterval time.Duration
refreshChan chan struct{}
refresher refreshService
}
func newRefreshTicker(ctx context.Context, refreshIntervalSeconds int64, refresh refreshService) (*refreshTicker, error) {
const op = "daemon.newRefreshTicker"
switch {
case refreshIntervalSeconds == 0:
return nil, errors.New(ctx, errors.InvalidParameter, op, "refresh interval seconds is missing")
case util.IsNil(refresh):
return nil, errors.New(ctx, errors.InvalidParameter, op, "refreshing function is missing")
}
refreshInterval := defaultRefreshInterval
if refreshIntervalSeconds != 0 {
refreshInterval = time.Duration(refreshIntervalSeconds) * time.Second
}
return &refreshTicker{
refreshInterval: refreshInterval,
refresher: refresh,
// We make this channel size 1 so if something happens midway through the refresh
// we can immediately refresh again immediately to pick up something that might have been
// missed the first time through.
refreshChan: make(chan struct{}, 1),
}, nil
}
// refresh starts the refresh logic if it is currently waiting. If refresh is
// currently happening then this queues a refresh for when the current refresh
// iteration is done, unless there is already a refresh queued in which case
// this is a noop.
func (rt *refreshTicker) refresh() {
select {
case rt.refreshChan <- struct{}{}:
default:
}
}
func (rt *refreshTicker) start(ctx context.Context) {
const op = "daemon.(refreshTicker).start"
timer := time.NewTimer(0)
for {
select {
case <-ctx.Done():
return
case <-timer.C:
case <-rt.refreshChan:
}
if err := rt.refresher.Refresh(ctx); err != nil {
event.WriteError(rt.tickerCtx, op, err)
}
timer.Reset(rt.refreshInterval)
}
}
@@ -0,0 +1,57 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"testing"
"time"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTickerRefresh(t *testing.T) {
ctx := context.Background()
refresher := &fakeRefresher{make(chan struct{})}
rt, err := newRefreshTicker(ctx, 60, refresher)
require.NoError(t, err)
tickerCtx, tickerCancel := context.WithCancel(ctx)
go rt.start(tickerCtx)
t.Cleanup(func() {
tickerCancel()
})
// let the normal start ticker refresh things,
<-refresher.called
testCtx, testCancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer testCancel()
rt.refresh()
select {
case <-refresher.called:
case <-testCtx.Done():
assert.Fail(t, "timed out waiting for the refresh ")
}
// wait and make sure we don't get yet another call
select {
case <-refresher.called:
assert.Fail(t, "received an unexpected refresh call")
case <-testCtx.Done():
}
}
type fakeRefresher struct {
called chan struct{}
}
func (r *fakeRefresher) Refresh(context.Context, ...cache.Option) error {
r.called <- struct{}{}
return nil
}
@@ -0,0 +1,133 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/util"
)
type refresher interface {
refresh()
}
// KeyringToken has keyring held auth token information.
type KeyringToken struct {
// The keyring type used by boundary to access the auth token
KeyringType string
// The token identifier for the provided keyring type that holds the auth token
TokenName string
}
// userTokenToAdd is the request body to this handler.
type UpsertTokenRequest struct {
// BoundaryAddr is a required field for all requests
BoundaryAddr string
// The id of the auth token asserted to be attempted to be added
AuthTokenId string
// The raw auth token for this user. Either this field or the Keyring field
// must be set but not both.
AuthToken string
// Keyring is the keyring info used when adding an auth token held in
// keyring to the daemon.
Keyring *KeyringToken
}
func newTokenHandlerFunc(ctx context.Context, repo *cache.Repository, refresher refresher) (http.HandlerFunc, error) {
const op = "daemon.newPersonaHandlerFunc"
switch {
case util.IsNil(repo):
return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is nil")
case util.IsNil(refresher):
return nil, errors.New(ctx, errors.InvalidParameter, op, "refresher is nil")
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if r.Method != http.MethodPost {
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var perReq UpsertTokenRequest
data, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, "unable to read request body", http.StatusBadRequest)
return
}
if err := json.Unmarshal(data, &perReq); err != nil {
writeError(w, "unable to parse request body", http.StatusBadRequest)
return
}
switch {
case perReq.BoundaryAddr == "":
writeError(w, "BoundaryAddr is a required field but was empty", http.StatusBadRequest)
return
case perReq.AuthTokenId == "":
writeError(w, "AuthTokenId is a required field but was empty", http.StatusBadRequest)
return
case perReq.Keyring == nil && perReq.AuthToken == "":
writeError(w, "Either keyring info or the authtoken must be provided but were empty", http.StatusBadRequest)
return
case perReq.Keyring != nil:
switch {
case perReq.Keyring.TokenName == "":
writeError(w, "TokenName is a required field but was empty", http.StatusBadRequest)
return
case perReq.Keyring.KeyringType == "":
writeError(w, "KeyringType is a required field but was empty", http.StatusBadRequest)
return
case perReq.Keyring.KeyringType == base.NoneKeyring:
writeError(w, fmt.Sprintf("KeyringType is set to %s which is not supported", perReq.Keyring.KeyringType), http.StatusBadRequest)
return
}
case perReq.AuthToken != "":
switch {
case !strings.HasPrefix(perReq.AuthToken, perReq.AuthTokenId):
writeError(w, "The auth token id doesn't match the auth token's prefix", http.StatusBadRequest)
return
}
}
tok, err := repo.LookupToken(ctx, perReq.AuthTokenId)
if err != nil {
writeError(w, "error performing auth token lookup", http.StatusInternalServerError)
return
}
switch {
case perReq.Keyring != nil:
kt := cache.KeyringToken{
KeyringType: perReq.Keyring.KeyringType,
TokenName: perReq.Keyring.TokenName,
AuthTokenId: perReq.AuthTokenId,
}
if err = repo.AddKeyringToken(ctx, perReq.BoundaryAddr, kt); err != nil {
writeError(w, "Failed to add a keyring stored token", http.StatusInternalServerError)
return
}
case perReq.AuthToken != "":
if err = repo.AddRawToken(ctx, perReq.BoundaryAddr, perReq.AuthToken); err != nil {
writeError(w, "Failed to add a raw token", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
if tok == nil {
refresher.refresh()
}
}, nil
}
@@ -0,0 +1,280 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ringToken is a test struct used to group a keyring type and token name
// so it can be used in an authtoken lookup function.
type ringToken struct {
k string
t string
}
// mapBasedAuthTokenKeyringLookup provides a fake KeyringTokenLookupFn that uses
// the provided map to perform lookups for the tokens
func mapBasedAuthTokenKeyringLookup(m map[ringToken]*authtokens.AuthToken) cache.KeyringTokenLookupFn {
return func(k, t string) *authtokens.AuthToken {
return m[ringToken{k, t}]
}
}
// sliceBasedAuthTokenBoundaryReader provides a fake BoundaryTokenReaderFn that uses
// the provided map to lookup an auth tokens information.
func sliceBasedAuthTokenBoundaryReader(s []*authtokens.AuthToken) cache.BoundaryTokenReaderFn {
return func(ctx context.Context, addr, at string) (*authtokens.AuthToken, error) {
for _, v := range s {
if at == v.Token {
return v, nil
}
}
return nil, errors.New("not found")
}
}
type testRefresher struct {
called bool
}
func (r *testRefresher) refresh() {
r.called = true
}
func addToken(t *testing.T, h http.Handler, tokReq *UpsertTokenRequest) *api.Error {
t.Helper()
b, err := json.Marshal(tokReq)
require.NoError(t, err)
rawBody := bytes.NewBuffer(b)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/tokens", rawBody)
h.ServeHTTP(rec, req)
if rec.Result().StatusCode == http.StatusNoContent {
return nil
}
apiErr := &api.Error{}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), apiErr))
return apiErr
}
func TestKeyringToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "user",
}
boundaryAuthTokens := []*authtokens.AuthToken{at}
keyring := "k"
tokenName := "t"
atMap := map[ringToken]*authtokens.AuthToken{
{keyring, tokenName}: at,
}
r, err := cache.NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
tr := &testRefresher{}
ph, err := newTokenHandlerFunc(ctx, r, tr)
require.NoError(t, err)
t.Run("missing keyring", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: "",
TokenName: tokenName,
},
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: at.Id,
}
apiErr := addToken(t, ph, pa)
require.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "KeyringType is a required field but was empty")
assert.False(t, tr.called)
})
t.Run("none keyring", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: base.NoneKeyring,
TokenName: tokenName,
},
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: at.Id,
}
apiErr := addToken(t, ph, pa)
require.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "KeyringType is set to none which is not supported")
assert.False(t, tr.called)
})
t.Run("missing token name", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: keyring,
TokenName: "",
},
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: at.Id,
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "TokenName is a required field but was empty")
assert.False(t, tr.called)
})
t.Run("missing boundary address", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
},
BoundaryAddr: "",
AuthTokenId: at.Id,
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "BoundaryAddr is a required field but was empty")
assert.False(t, tr.called)
})
t.Run("missing auth token id", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
},
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: "",
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "AuthTokenId is a required field but was empty")
assert.False(t, tr.called)
})
t.Run("mismatched auth token id", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
},
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: "at_doesntmatch",
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "Failed to add a keyring stored token")
assert.False(t, tr.called)
})
t.Run("success", func(t *testing.T) {
pa := &UpsertTokenRequest{
Keyring: &KeyringToken{
KeyringType: keyring,
TokenName: tokenName,
},
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: at.Id,
}
apiErr := addToken(t, ph, pa)
assert.Nil(t, apiErr)
assert.True(t, tr.called)
p, err := r.LookupToken(ctx, pa.AuthTokenId)
require.NoError(t, err)
assert.NotNil(t, p)
assert.Equal(t, at.Id, p.Id)
})
}
func TestKeyringlessToken(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: "user",
}
boundaryAuthTokens := []*authtokens.AuthToken{at}
atMap := map[ringToken]*authtokens.AuthToken{}
r, err := cache.NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens))
require.NoError(t, err)
tr := &testRefresher{}
ph, err := newTokenHandlerFunc(ctx, r, tr)
require.NoError(t, err)
t.Run("missing boundary address", func(t *testing.T) {
pa := &UpsertTokenRequest{
BoundaryAddr: "",
AuthTokenId: at.Id,
AuthToken: at.Token,
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "BoundaryAddr is a required field but was empty")
assert.False(t, tr.called)
})
t.Run("missing auth token id", func(t *testing.T) {
pa := &UpsertTokenRequest{
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: "",
AuthToken: at.Token,
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "AuthTokenId is a required field but was empty")
assert.False(t, tr.called)
})
t.Run("mismatched auth token id", func(t *testing.T) {
pa := &UpsertTokenRequest{
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: "at_doesntmatch",
AuthToken: at.Token,
}
apiErr := addToken(t, ph, pa)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, "The auth token id doesn't match the auth token's prefix")
assert.False(t, tr.called)
})
t.Run("success", func(t *testing.T) {
pa := &UpsertTokenRequest{
BoundaryAddr: "http://127.0.0.1",
AuthTokenId: at.Id,
AuthToken: at.Token,
}
apiErr := addToken(t, ph, pa)
assert.Nil(t, apiErr)
assert.True(t, tr.called)
p, err := r.LookupToken(ctx, pa.AuthTokenId)
require.NoError(t, err)
assert.NotNil(t, p)
assert.Equal(t, at.Id, p.Id)
})
}
@@ -0,0 +1,35 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"fmt"
"net/http"
"github.com/hashicorp/boundary/internal/util"
"github.com/hashicorp/boundary/version"
)
const (
VersionHeaderKey = "boundary_version"
)
// versionEnforcement is an interceptor which, if the boundary version is included
// in a request, enforces that it matches the version of the daemon currently
// running. If no version is provided, the inteceptor passes the request through.
func versionEnforcement(h http.Handler) http.Handler {
if util.IsNil(h) {
return nil
}
needVer := version.Get().VersionNumber()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotVer := r.Header.Get(VersionHeaderKey)
if gotVer != "" && needVer != gotVer {
writeError(w, fmt.Sprintf("Version mismatch between requester (%q) and daemon (%q). You may need to restart your daemon.", gotVer, needVer), http.StatusBadRequest)
return
}
h.ServeHTTP(w, r)
})
}
@@ -0,0 +1,51 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/boundary/version"
"github.com/stretchr/testify/assert"
)
func TestVersionEnforcement(t *testing.T) {
var called bool
calledHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})
h := versionEnforcement(calledHandler)
t.Run("no version provided", func(t *testing.T) {
called = false
req := httptest.NewRequest("", "/test", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.True(t, called)
assert.Equal(t, http.StatusNoContent, w.Result().StatusCode)
})
t.Run("bad version provided", func(t *testing.T) {
called = false
req := httptest.NewRequest("", "/test", nil)
req.Header.Set(VersionHeaderKey, "badversion")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.False(t, called)
assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
})
t.Run("correct version provided", func(t *testing.T) {
called = false
req := httptest.NewRequest("", "/test", nil)
req.Header.Set(VersionHeaderKey, version.Get().VersionNumber())
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.True(t, called)
assert.Equal(t, http.StatusNoContent, w.Result().StatusCode)
})
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package db
import (
"context"
_ "embed"
"fmt"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/go-dbw"
)
//go:embed schema.sql
var cacheSchema string
// DefaultStoreUrl uses a temp in-memory sqlite database see: https://www.sqlite.org/inmemorydb.html
const DefaultStoreUrl = "file::memory:?_pragma=foreign_keys(1)"
// Open creates a database connection. WithUrl is supported, but by default it
// uses an in memory sqlite table. Sqlite is the only supported dbtype.
func Open(ctx context.Context, opt ...Option) (*db.DB, error) {
const op = "db.Open"
opts, err := getOpts(opt...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
var url string
switch {
case opts.withUrl != "":
url = opts.withUrl
default:
url = DefaultStoreUrl
}
conn, err := db.Open(ctx, db.Sqlite, url)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
conn.Debug(opts.withDebug)
switch {
case opts.withDbType == dbw.Sqlite:
if err := createTables(ctx, conn); err != nil {
errors.Wrap(ctx, err, op)
}
default:
return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("%q is not a supported cache store type", opts.withDbType))
}
return conn, nil
}
func createTables(ctx context.Context, conn *db.DB) error {
const op = "db.createTables"
rw := db.New(conn)
if _, err := rw.Exec(ctx, cacheSchema, nil); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
@@ -0,0 +1,50 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package db
import (
"github.com/hashicorp/go-dbw"
)
type options struct {
withDebug bool
withUrl string
withDbType dbw.DbType
}
// Option - how options are passed as args
type Option func(*options) error
func getDefaultOptions() options {
return options{
withDbType: dbw.Sqlite,
}
}
func getOpts(opt ...Option) (options, error) {
opts := getDefaultOptions()
for _, o := range opt {
if err := o(&opts); err != nil {
return opts, err
}
}
return opts, nil
}
// WithUrls provides optional url
func WithUrl(url string) Option {
return func(o *options) error {
o.withUrl = url
return nil
}
}
// WithDebug provides an optional debug flag.
func WithDebug(debug bool) Option {
return func(o *options) error {
o.withDebug = debug
return nil
}
}
@@ -0,0 +1,40 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package db
import (
"testing"
"github.com/hashicorp/go-dbw"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetOpts(t *testing.T) {
t.Parallel()
t.Run("default", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
testOpts := options{
withDbType: dbw.Sqlite,
}
assert.Equal(t, opts, testOpts)
})
t.Run("WithUrl", func(t *testing.T) {
url := "something"
opts, err := getOpts(WithUrl(url))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withUrl = url
assert.Equal(t, opts, testOpts)
})
t.Run("WithDebug", func(t *testing.T) {
opts, err := getOpts(WithDebug(true))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withDebug = true
assert.Equal(t, opts, testOpts)
})
}
+116
View File
@@ -0,0 +1,116 @@
-- Copyright (c) HashiCorp, Inc.
-- SPDX-License-Identifier: BUSL-1.1
begin;
-- user contains the boundary user information for the boundary user that owns
-- the information in the cache.
create table if not exists user (
-- The id of the user resource from boundary
id text not null primary key
check (length(id) > 0),
-- The address of the boundary instance that this user id comes from
address text not null
check (length(address) > 0)
);
-- Contains the boundary auth token
create table if not exists auth_token (
-- id is the boundary id of the auth token
id text not null primary key
check (length(id) > 0),
-- user id is the boundary user id the auth token is associated with
user_id text not null
references user(id)
on delete cascade,
-- the last time this the auth token was used on this machine to access
-- boundary outside of the context of the cache.
last_accessed_time timestamp not null
default (strftime('%Y-%m-%d %H:%M:%f','now')),
expiration_time timestamp not null
);
-- *delete_orphaned_users triggers delete a user when it no longer has any
-- auth tokens associated with them
create trigger token_update_delete_orphaned_users after update on auth_token
begin
delete from user
where
id not in (select user_id from auth_token);
end;
create trigger token_delete_delete_orphaned_users after delete on auth_token
begin
delete from user
where
id not in (select user_id from auth_token);
end;
create table if not exists keyring_token (
-- the name of the keyring type on the local machine
keyring_type text not null
check (length(keyring_type) > 0),
-- the name of the stored token on the keyring
token_name text not null
check (length(token_name) > 0),
-- the boundary auth token id stored at in this keyring using the token name
auth_token_id text not null
references auth_token(id)
on delete cascade,
primary key (keyring_type, token_name)
);
-- target contains cached boundary target resource for a specific user and with
-- specific fields extracted to facilitate searching over those fields
create table if not exists target (
-- the boundary user id of the user who has was able to read/list this target
user_id text not null
references user(id)
on delete cascade,
-- the boundary id of the target
id text not null
check (length(id) > 0),
-- the following fields are used for searching and are set to the values
-- from the boundary resource
name text,
description text,
address text,
-- item is the json representation of this resource from the perspective of
-- the the requesting user.
item text,
primary key (user_id, id)
);
-- session contains cached boundary session resource for a specific user and
-- with specific fields extracted to facilitate searching over those fields
create table if not exists session (
-- the boundary user id of the user who has was able to read/list this resource
user_id text not null
references user(id)
on delete cascade,
-- the resource id from boundary of this session
id text not null
check (length(id) > 0),
-- the following fields are used for searching and are set to the values
-- from the boundary resource
endpoint text,
type text,
status text,
-- item is the json representation of this resource from the perspective of
-- of the user whose id is set in user_id
item text,
primary key (user_id, id)
);
-- contains errors from the last attempt to sync data from boundary for a
-- specific resource type
create table if not exists api_error (
user_id text not null
references user(id)
on delete cascade,
resource_type text not null,
error text not null,
create_time timestamp not null default current_timestamp,
primary key (user_id, resource_type)
);
commit;
+4 -4
View File
@@ -102,7 +102,7 @@ type Command struct {
FlagTokenName string
FlagKeyringType string
FlagRecoveryConfig string
flagOutputCurlString bool
FlagOutputCurlString bool
FlagScopeId string
FlagScopeName string
@@ -204,8 +204,8 @@ func (c *Command) Client(opt ...Option) (*api.Client, error) {
return nil, err
}
if c.flagOutputCurlString {
config.OutputCurlString = c.flagOutputCurlString
if c.FlagOutputCurlString {
config.OutputCurlString = c.FlagOutputCurlString
}
c.client, err = api.NewClient(config)
@@ -452,7 +452,7 @@ func (c *Command) FlagSet(bit FlagSetBit) *FlagSets {
f.BoolVar(&BoolVar{
Name: "output-curl-string",
Target: &c.flagOutputCurlString,
Target: &c.FlagOutputCurlString,
Usage: "Instead of executing the request, print an equivalent cURL command string and exit.",
})
}
+764 -898
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -89,7 +89,7 @@ func saveAndOrPrintToken(c *base.Command, result *authmethods.AuthenticateResult
}
if !gotErr {
c.UI.Output("\nThe token was successfully stored in the chosen keyring and is not displayed here.")
c.UI.Output(fmt.Sprintf("\nThe token name %q was successfully stored in the chosen keyring and is not displayed here.", tokenName))
}
}
}
@@ -0,0 +1,38 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package unsupported
import (
"errors"
"fmt"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
)
var _ cli.Command = (*UnsupportedCommand)(nil)
// UnsupportedCommand is a command that simply prints out a message indicating
// the requested command is not supported on this platform.
type UnsupportedCommand struct {
*base.Command
CommandName string
}
func (c *UnsupportedCommand) notice() string {
return fmt.Sprintf("'boundary %s' is not supported on this platform.", c.CommandName)
}
func (c *UnsupportedCommand) Synopsis() string {
return c.notice()
}
func (c *UnsupportedCommand) Help() string {
return c.notice()
}
func (c *UnsupportedCommand) Run(args []string) int {
c.Command.PrintCliError(errors.New(c.notice()))
return base.CommandUserError
}
-3
View File
@@ -1,9 +1,6 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
// +build windows
package cmd
// MakeSigUSR2Ch does nothing useful on Windows.
+3 -2
View File
@@ -144,7 +144,7 @@ func Run(args []string) int {
// RunCustom allows passing in a base command template to pass to other
// commands. Currently, this is only used for setting a custom token helper.
func RunCustom(args []string, runOpts *RunOptions) int {
func RunCustom(args []string, runOpts *RunOptions) (exitCode int) {
if runOpts == nil {
runOpts = &RunOptions{}
}
@@ -256,7 +256,8 @@ func RunCustom(args []string, runOpts *RunOptions) int {
AutocompleteNoDefaultFlags: true,
}
exitCode, err := cli.Run()
var err error
exitCode, err = cli.Run()
if outputCurlString {
if exitCode == 0 {
fmt.Fprint(runOpts.Stderr, "Could not generate cURL command\n")
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cmd
import (
"github.com/hashicorp/boundary/internal/clientcache/cmd/daemon"
"github.com/hashicorp/boundary/internal/clientcache/cmd/search"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
)
func init() {
commandFactoryWrapper = daemonWrap
extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
delete(Commands, "daemon")
Commands["daemon start"] = func() (cli.Command, error) {
return &daemon.StartCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon stop"] = func() (cli.Command, error) {
return &daemon.StopCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon add-token"] = func() (cli.Command, error) {
return &daemon.AddTokenCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["search"] = func() (cli.Command, error) {
return &search.SearchCommand{
Command: base.NewCommand(ui),
}, nil
}
})
}
// daemonWrap wraps the provided wrappableCommand with a daemon writer which
// conditionally starts the client side daemon after the command completes
func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory {
return daemon.Wrap(ui, c)
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build amd64 || arm64
package cmd
import (
"github.com/hashicorp/boundary/internal/clientcache/cmd/daemon"
"github.com/hashicorp/boundary/internal/clientcache/cmd/search"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
)
func init() {
commandFactoryWrapper = daemonWrap
extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
delete(Commands, "daemon")
Commands["daemon start"] = func() (cli.Command, error) {
return &daemon.StartCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon stop"] = func() (cli.Command, error) {
return &daemon.StopCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon add-token"] = func() (cli.Command, error) {
return &daemon.AddTokenCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["search"] = func() (cli.Command, error) {
return &search.SearchCommand{
Command: base.NewCommand(ui),
}, nil
}
})
}
// daemonWrap wraps the provided wrappableCommand with a daemon writer which
// conditionally starts the client side daemon after the command completes
func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory {
return daemon.Wrap(ui, c)
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cmd
import (
"github.com/hashicorp/boundary/internal/clientcache/cmd/daemon"
"github.com/hashicorp/boundary/internal/clientcache/cmd/search"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
)
func init() {
commandFactoryWrapper = daemonWrap
extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
delete(Commands, "daemon")
Commands["daemon start"] = func() (cli.Command, error) {
return &daemon.StartCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon stop"] = func() (cli.Command, error) {
return &daemon.StopCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon add-token"] = func() (cli.Command, error) {
return &daemon.AddTokenCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["search"] = func() (cli.Command, error) {
return &search.SearchCommand{
Command: base.NewCommand(ui),
}, nil
}
})
}
// daemonWrap wraps the provided wrappableCommand with a daemon writer which
// conditionally starts the client side daemon after the command completes
func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory {
return daemon.Wrap(ui, c)
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build amd64 || arm64
package cmd
import (
"github.com/hashicorp/boundary/internal/clientcache/cmd/daemon"
"github.com/hashicorp/boundary/internal/clientcache/cmd/search"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
)
func init() {
commandFactoryWrapper = daemonWrap
extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
delete(Commands, "daemon")
Commands["daemon start"] = func() (cli.Command, error) {
return &daemon.StartCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon stop"] = func() (cli.Command, error) {
return &daemon.StopCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["daemon add-token"] = func() (cli.Command, error) {
return &daemon.AddTokenCommand{
Command: base.NewCommand(ui),
}, nil
}
Commands["search"] = func() (cli.Command, error) {
return &search.SearchCommand{
Command: base.NewCommand(ui),
}, nil
}
})
}
// daemonWrap wraps the provided wrappableCommand with a daemon writer which
// conditionally starts the client side daemon after the command completes
func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory {
return daemon.Wrap(ui, c)
}
+17
View File
@@ -14,6 +14,7 @@ import (
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/go-dbw"
_ "github.com/jackc/pgx/v5"
"gorm.io/driver/postgres"
)
@@ -22,17 +23,25 @@ func init() {
dbw.InitNonUpdatableFields([]string{"PublicId", "CreateTime", "UpdateTime"})
}
// sqliteOpen is a function used to get the dbw.Dialector for sqlite. It returns
// an error if there is any problem opening it.
var sqliteOpen = func(string) (dbw.Dialector, error) {
return nil, fmt.Errorf("sqlite is not supported on this platform")
}
type DbType int
const (
UnknownDB DbType = 0
Postgres DbType = 1
Sqlite DbType = 2
)
func (db DbType) String() string {
return [...]string{
"unknown",
"postgres",
"sqlite",
}[db]
}
@@ -40,6 +49,8 @@ func StringToDbType(dialect string) (DbType, error) {
switch dialect {
case "postgres":
return Postgres, nil
case "sqlite":
return Sqlite, nil
default:
return UnknownDB, fmt.Errorf("%s is an unknown dialect", dialect)
}
@@ -148,6 +159,12 @@ func Open(ctx context.Context, dbType DbType, connectionUrl string, opt ...Optio
DSN: connectionUrl,
},
)
case Sqlite:
var err error
dialect, err = sqliteOpen(connectionUrl)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
default:
return nil, fmt.Errorf("unable to open %s database type", dbType)
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package db
import (
"github.com/glebarez/sqlite"
"github.com/hashicorp/go-dbw"
)
func init() {
sqliteOpen = supportedSqlite
}
func supportedSqlite(s string) (dbw.Dialector, error) {
return sqlite.Open(s), nil
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build amd64 || arm64
package db
import (
"github.com/glebarez/sqlite"
"github.com/hashicorp/go-dbw"
)
func init() {
sqliteOpen = supportedSqlite
}
func supportedSqlite(s string) (dbw.Dialector, error) {
return sqlite.Open(s), nil
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package db
import (
"github.com/glebarez/sqlite"
"github.com/hashicorp/go-dbw"
)
func init() {
sqliteOpen = supportedSqlite
}
func supportedSqlite(s string) (dbw.Dialector, error) {
return sqlite.Open(s), nil
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build amd64 || arm64
package db
import (
"github.com/glebarez/sqlite"
"github.com/hashicorp/go-dbw"
)
func init() {
sqliteOpen = supportedSqlite
}
func supportedSqlite(s string) (dbw.Dialector, error) {
return sqlite.Open(s), nil
}